System Call: L’Interfaccia tra Applicazioni e Kernel

Introduzione

Le system call rappresentano l’interfaccia fondamentale attraverso cui i programmi applicativi interagiscono con il sistema operativo. Costituiscono il ponte tra lo spazio utente (user space) e lo spazio kernel (kernel space), permettendo alle applicazioni di richiedere servizi privilegiati al sistema operativo in modo controllato e sicuro.

Per comprendere meglio questo concetto, immaginiamo un’applicazione che voglia leggere un file dal disco. L’applicazione non può accedere direttamente all’hardware del disco per motivi di sicurezza e stabilità del sistema. Invece, l’applicazione effettua una richiesta al sistema operativo attraverso una system call (in questo caso read()), e il kernel si occupa di:

Questo meccanismo garantisce che nessuna applicazione possa compromettere il sistema accedendo direttamente all’hardware o alle risorse di altri processi.

In questa lezione esploreremo in profondità il concetto di system call, il loro funzionamento interno, le diverse categorie e i meccanismi di invocazione. La comprensione delle system call è essenziale per chiunque voglia:

Concetti Fondamentali

User Space vs Kernel Space

I moderni sistemi operativi implementano una separazione rigida tra due modalità di esecuzione:

User Space (Spazio Utente)

Lo user space è l’ambiente in cui eseguono tutte le applicazioni normali che utilizziamo quotidianamente: browser web, editor di testo, giochi, e qualsiasi altro programma. Le caratteristiche principali sono:

Kernel Space (Spazio Kernel)

Il kernel space è dove risiede il cuore del sistema operativo. Solo il codice del kernel e i driver autorizzati possono eseguire in questo spazio. Le caratteristiche sono:

Protection Rings: L’Implementazione Hardware

Questa separazione è implementata attraverso i protection ring del processore. Nelle architetture x86/x86-64 esistono 4 ring (0-3):

Quando un’applicazione in Ring 3 tenta di eseguire un’istruzione privilegiata o di accedere memoria non autorizzata, il processore genera automaticamente un’eccezione che passa il controllo al kernel. Il kernel decide quindi se terminare il processo (es. SIGSEGV per accesso memoria illegale) o se gestire la richiesta (es. page fault per memoria non ancora caricata).

Esempio pratico della separazione:

Immaginate che un’applicazione voglia scrivere un byte nel file “output.txt”:

  1. User Space: L’applicazione chiama write(fd, "A", 1) - questa è una funzione della libreria C
  2. Richiesta di passaggio: La libreria C prepara i parametri ed esegue l’istruzione syscall (o int 0x80 su sistemi più vecchi)
  3. Cambio modalità: Il processore passa da Ring 3 a Ring 0 automaticamente
  4. Kernel Space: Il kernel riceve la richiesta, valida i parametri, controlla i permessi, e scrive fisicamente sul disco
  5. Ritorno: Il kernel ritorna il risultato e il processore torna in Ring 3
  6. User Space: L’applicazione riceve il numero di byte scritti (o un errore)

Questa danza tra user space e kernel space avviene migliaia di volte al secondo in un sistema moderno, ma è invisibile all’utente finale.

Perché le System Call sono Necessarie

Le applicazioni utente non possono accedere direttamente a risorse critiche come:

Vantaggi della mediazione tramite system call:

Vediamo ora perché avere il kernel come mediatore attraverso le system call è così importante:

  1. Sicurezza: Il kernel può verificare ogni richiesta e negare accessi non autorizzati. Quando chiamate open("/etc/shadow") (il file che contiene le password degli utenti in Linux), il kernel controlla se il vostro processo ha i permessi necessari. Se siete un utente normale, la chiamata fallirà con errore EACCES (Permission Denied). Questo controllo avviene nel kernel, dove voi non potete bypassarlo. Se le applicazioni potessero accedere direttamente ai file, questo controllo sarebbe impossibile da applicare.

  2. Stabilità: Errori nelle applicazioni non possono corrompere il sistema. Se il vostro programma ha un bug e tenta di accedere a un puntatore NULL, ricevete un SIGSEGV (segmentation fault) e il vostro processo termina, ma il sistema operativo continua a funzionare normalmente. Questo perché il kernel rileva l’accesso illegale alla memoria e termina il processo prima che possa fare danni. Se invece i programmi potessero accedere direttamente alla memoria fisica, un accesso errato potrebbe corrompere le strutture dati del kernel stesso, causando il crash dell’intero sistema.

  3. Astrazione: Le applicazioni non devono conoscere i dettagli hardware. Quando scrivete write(fd, buffer, size), non vi importa se state scrivendo su un hard disk SATA, un SSD NVMe, un file su una condivisione di rete NFS, o su un dispositivo USB. Il kernel nasconde tutti questi dettagli e presenta un’interfaccia uniforme. Lo stesso codice funziona indipendentemente dall’hardware sottostante. Questa è un’astrazione potentissima che semplifica enormemente la programmazione.

  4. Portabilità: Lo stesso codice funziona su hardware diverso. Un programma compilato per Linux su architettura x86-64 che usa system call POSIX standard può essere ricompilato e funzionare su ARM, RISC-V, o qualsiasi altra architettura supportata da Linux, senza modifiche al codice. Il kernel si occupa di tradurre le richieste standard in operazioni specifiche per l’hardware particolare. Senza questa astrazione, dovreste riscrivere parti del vostro codice per ogni piattaforma.

  5. Condivisione controllata: Il kernel coordina l’accesso alle risorse condivise. Se due processi tentano di scrivere sullo stesso file contemporaneamente, il kernel serializza le operazioni per evitare corruzione dei dati. Se due processi vogliono usare la scheda di rete, il kernel intercala i loro pacchetti assicurando che arrivino correttamente a destinazione. Senza questa coordinazione centrale, il caos sarebbe inevitabile – immaginate due programmi che tentano di disegnare sullo schermo nello stesso momento, o due processi che cercano di modificare lo stesso file simultaneamente!

Questa architettura crea una chiara separazione tra “cosa” volete fare (aprire un file, leggere dati, inviare un pacchetto di rete) e “come” viene fatto a livello hardware. Voi, come programmatori, dichiarate l’intenzione attraverso le system call, e il kernel si occupa dei dettagli implementativi. Questa separazione è una delle idee fondamentali che rendono possibili i moderni sistemi operativi multitasking e multiutente.

Definizione di System Call

Una system call è una chiamata programmata a un servizio fornito dal kernel del sistema operativo. È l’unico meccanismo attraverso cui un programma in user space può richiedere al kernel di eseguire operazioni privilegiate per suo conto.

Proviamo a comprendere meglio cosa significa questa definizione. Quando scriviamo un programma in C e utilizziamo una funzione come read() per leggere da un file, potremmo pensare che sia una semplice funzione di libreria come strlen() o printf(). In realtà, read() è molto più di questo: è un punto di ingresso verso il kernel del sistema operativo. Non è il nostro codice che accede fisicamente al disco rigido – questo sarebbe impossibile e pericoloso. È il kernel che, su nostra richiesta tramite la system call read(), esegue l’operazione privilegiata di accedere all’hardware.

Questa distinzione è fondamentale: le funzioni normali operano interamente nello spazio della nostra applicazione, usando solo le risorse già assegnate al nostro programma. Le system call, invece, richiedono l’intervento del kernel, che ha accesso completo all’hardware e alle risorse di sistema.

Caratteristiche principali:

Meccanismo di Invocazione delle System Call

Il Processo Step-by-Step

Quando un’applicazione invoca una system call, avviene una sequenza complessa di operazioni che coinvolgono sia il software (libreria C, kernel) che l’hardware (processore, MMU). Analizziamo ogni passo in dettaglio:

1. Preparazione dei Parametri

Tutto inizia con una semplice chiamata in linguaggio C:

// L'applicazione prepara i parametri
int fd = open("/etc/passwd", O_RDONLY);

Analizziamo questa riga:

A questo punto siamo ancora completamente nello user space. La variabile fd non è ancora stata inizializzata e nessuna interazione con il kernel è avvenuta.

2. Invocazione della Wrapper Function

La libreria C (libc) fornisce una funzione wrapper per ogni system call. Questo wrapper ha tre compiti fondamentali:

a) Carica i parametri nei registri appropriati: Su x86-64, i parametri vengono caricati in registri specifici secondo la calling convention:

b) Carica il numero della system call: Ogni system call ha un numero univoco. Il wrapper carica questo numero nel registro %rax.

c) Esegue l’istruzione trap/interrupt: Questa è l’istruzione che causa il passaggio da user mode a kernel mode.

3. Trap/Interrupt Software

L’istruzione chiave che causa il cambio di modalità è:

; Su x86-64, viene eseguita l'istruzione
syscall  ; oppure int 0x80 su sistemi più vecchi

Cosa succede esattamente quando viene eseguita syscall:

a) Salvataggio dello stato: Il processore salva automaticamente:

b) Cambio di privilegio: Il processore passa da Ring 3 (user mode) a Ring 0 (kernel mode). Questo è un cambio hardware - il bit CPL (Current Privilege Level) nel registro CS viene impostato a 0.

c) Salto all’handler: Il processore salta all’indirizzo del system call handler nel kernel. Questo indirizzo è preconfigurato in un registro speciale chiamato MSR (Model Specific Register) IA32_LSTAR.

d) Cambio stack: Il processore passa dallo stack utente allo stack kernel. Ogni processo ha due stack: uno user space e uno kernel space.

4. System Call Handler nel Kernel

Una volta nel kernel, entra in gioco il system call handler. Ecco come appare (pseudocodice semplificato del kernel Linux):

// Pseudocodice del kernel
SYSCALL_DEFINE3(open, const char __user *, filename, 
                int, flags, umode_t, mode) {
    // Validazione parametri
    // Controlli di sicurezza
    // Esecuzione della logica
    // Ritorno del risultato
}

Analizziamo cosa fa questo codice:

a) Definizione della system call:

b) Validazione parametri - Passo cruciale per la sicurezza:

// Il kernel deve validare il puntatore filename
if (!access_ok(filename, VERIFY_READ)) {
    return -EFAULT;  // Indirizzo non valido
}

// Copia la stringa dallo user space al kernel space
// (non possiamo fidarci di puntatori user space)
char *kernel_filename = getname(filename);
if (IS_ERR(kernel_filename)) {
    return PTR_ERR(kernel_filename);
}

Perché questa validazione è necessaria? Un’applicazione malintenzionata potrebbe passare:

c) Controlli di permessi:

// Verifica che l'utente abbia permessi per aprire il file
struct inode *inode = path_lookup(kernel_filename);
if (!inode) {
    return -ENOENT;  // File non esiste
}

if (!inode_permission(inode, MAY_READ)) {
    return -EACCES;  // Permesso negato
}

Il kernel controlla:

d) Esecuzione della logica:

// Alloca un file descriptor
int fd = get_unused_fd_flags(flags);
if (fd < 0) {
    return fd;  // Troppi file aperti
}

// Apre effettivamente il file
struct file *filp = do_filp_open(kernel_filename, flags, mode);
if (IS_ERR(filp)) {
    put_unused_fd(fd);
    return PTR_ERR(filp);
}

// Installa il file descriptor nella tabella del processo
fd_install(fd, filp);

Cosa succede qui:

e) Preparazione del risultato:

// Ritorna il file descriptor (numero positivo)
// oppure codice di errore negativo
return fd;

Il kernel prepara il valore di ritorno nel registro %rax. Se tutto è andato bene, %rax contiene il file descriptor (es. 3). Se c’è stato un errore, %rax contiene un numero negativo (es. -ENOENT = -2).

5. Ritorno allo User Space

Il kernel ha completato il suo lavoro. Ora deve restituire il controllo all’applicazione:

a) Ripristino dello stato del processore:

b) Cambio di privilegio: Il processore passa da Ring 0 (kernel mode) a Ring 3 (user mode)

c) Ritorno all’applicazione: L’esecuzione riprende dall’istruzione immediatamente successiva a syscall

6. Gestione del Risultato nella Wrapper Function

La wrapper function della libc riceve il valore in %rax e lo elabora:

// Pseudocodice della wrapper function nella libc
long sys_open(const char *pathname, int flags, mode_t mode) {
    long result;
    
    // Esegue la system call (in assembly)
    asm volatile(
        "mov %1, %%rdi\n"      // pathname in %rdi
        "mov %2, %%rsi\n"      // flags in %rsi
        "mov %3, %%rdx\n"      // mode in %rdx
        "mov $2, %%rax\n"      // numero syscall open (2) in %rax
        "syscall\n"            // esegue system call
        "mov %%rax, %0\n"      // risultato in result
        : "=r" (result)
        : "r" (pathname), "r" (flags), "r" (mode)
        : "%rax", "%rdi", "%rsi", "%rdx"
    );
    
    // Gestisce il risultato
    if (result < 0) {
        // Errore: imposta errno al valore positivo dell'errore
        errno = -result;
        return -1;
    }
    
    // Successo: ritorna il file descriptor
    return result;
}

Cosa fa questo codice:

  1. Prepara i registri: Carica i parametri nei registri secondo la calling convention
  2. Esegue syscall: L’istruzione che causa il passaggio al kernel
  3. Controlla il risultato: Se result < 0, c’è stato un errore
  4. Imposta errno: In caso di errore, errno viene impostato al codice di errore (positivo)
  5. Ritorna: Ritorna -1 in caso di errore, o il file descriptor in caso di successo

7. Gestione nell’Applicazione

Finalmente, il controllo torna al nostro codice C:

int fd = open("/etc/passwd", O_RDONLY);

// A questo punto fd contiene il risultato
if (fd == -1) {
    // Errore: errno è stato impostato dalla wrapper function
    
    // Stampa il messaggio di errore corrispondente a errno
    perror("open");
    
    // oppure
    printf("Errore: %s\n", strerror(errno));
    
    // errno potrebbe essere:
    // ENOENT (2) - file non esiste
    // EACCES (13) - permesso negato
    // EMFILE (24) - troppi file aperti
    // etc.
    
    exit(EXIT_FAILURE);
}

// Successo: fd contiene un numero >= 0 (es. 3)
printf("File aperto con fd = %d\n", fd);

// Ora possiamo usare fd per leggere dal file
char buffer[100];
ssize_t n = read(fd, buffer, sizeof(buffer));
// ... read farà un'altra system call con lo stesso processo ...

Riepilogo del Viaggio Completo:

  1. User Space: Chiamata open("/etc/passwd", O_RDONLY) nel codice C
  2. Libc Wrapper: Prepara registri ed esegue syscall
  3. Hardware: Passa da Ring 3 a Ring 0, salta all’handler
  4. Kernel: Valida, controlla permessi, apre file, alloca fd
  5. Hardware: Passa da Ring 0 a Ring 3, ritorna all’applicazione
  6. Libc Wrapper: Converte risultato e imposta errno se necessario
  7. User Space: Riceve fd o -1 con errno impostato

Tutto questo processo avviene in microsecondi, ma comprenderlo è fondamentale per capire come funzionano i sistemi operativi moderni!

Diagramma del Flusso

User Space                          Kernel Space
    │                                    │
    │  1. Chiamata a open()              │
    │     (wrapper libc)                 │
    │                                    │
    │  2. Caricamento parametri          │
    │     nei registri                   │
    │                                    │
    │  3. syscall/int 0x80               │
    ├────────────────────────────────────┤
    │                                    │ Context Switch
    │                              4. Handler riceve
    │                                 controllo
    │                                    │
    │                              5. Valida parametri
    │                                    │
    │                              6. Controlla permessi
    │                                    │
    │                              7. Esegue operazione
    │                                    │
    │                              8. Prepara risultato
    ├────────────────────────────────────┤
    │                                    │ Context Switch
    │  9. Riceve risultato               │
    │                                    │
    │ 10. Imposta errno se errore        │
    │                                    │
    │ 11. Ritorna al chiamante           │
    │                                    │

Numeri delle System Call

Ogni system call è identificata da un numero univoco. Questo numero è ciò che il kernel usa per capire quale servizio l’applicazione sta richiedendo. Quando eseguite l’istruzione syscall, il processore passa il controllo al kernel, e il kernel guarda nel registro RAX (su x86-64) per vedere quale numero di system call è stato richiesto. Basandosi su questo numero, il kernel salta alla funzione appropriata che implementa quella particolare system call.

Questi numeri sono definiti in file header del kernel e sono specifici dell’architettura. Questo significa che lo stesso servizio (ad esempio, “aprire un file”) può avere numeri diversi su architetture diverse. Questo è il motivo per cui non dovreste mai chiamare le system call direttamente usando il numero – usate sempre le funzioni wrapper della libc che si occupano di questa conversione in modo portabile.

Vediamo alcuni esempi su Linux x86-64:

// Alcuni esempi di numeri di system call (x86-64)
#define __NR_read      0
#define __NR_write     1
#define __NR_open      2
#define __NR_close     3
#define __NR_stat      4
#define __NR_fstat     5
#define __NR_lstat     6
#define __NR_poll      7
#define __NR_fork     57
#define __NR_execve   59
#define __NR_exit     60

Notate alcuni dettagli interessanti. I numeri più bassi (0-10) sono assegnati alle system call più comuni e fondamentali come read, write, open, close. Questo non è casuale – storicamente, questi numeri sono stati assegnati alle operazioni più frequenti per rendere il codice del kernel leggermente più efficiente (anche se l’impatto in termini di performance è minimo sui processori moderni).

Il numero 2 è assegnato a open, una delle system call più usate. Ogni volta che un programma apre un file – che succede centinaia o migliaia di volte durante l’esecuzione di applicazioni complesse – il numero 2 viene caricato in RAX e l’istruzione syscall viene eseguita.

fork ha il numero 57 e execve il 59. Questi numeri più alti riflettono il fatto che sono state aggiunte al sistema in un secondo momento rispetto alle operazioni di I/O basilari. La numerazione delle system call riflette un po’ la storia evolutiva di Unix.

Differenze tra architetture:

Questi numeri sono specifici dell’architettura e possono variare significativamente tra:

Questa variabilità è gestita dalla libc (libreria C standard), che fornisce le funzioni wrapper come read(), write(), open(). Quando compilate il vostro programma per una specifica architettura, la libc corretta viene linkata, e le funzioni wrapper useranno i numeri appropriati per quella architettura. Questo è un altro livello di astrazione che rende il codice portabile.

Stabilità dell’ABI (Application Binary Interface):

Anche se i numeri possono essere diversi tra architetture, c’è una regola ferrea: per una data architettura, i numeri delle system call NON cambiano mai. Questo fa parte della garanzia di stabilità dell’ABI di Linux. Un programma compilato per x86-64 nel 2010 funzionerà ancora su un kernel Linux del 2026 perché i numeri delle system call sono rimasti gli stessi. Questo permette di eseguire software binario vecchio di decenni su kernel moderni – una caratteristica cruciale per la compatibilità all’indietro.

Nuove system call vengono aggiunte assegnando loro nuovi numeri (tipicamente in ordine crescente), ma i numeri esistenti non vengono mai riutilizzati o modificati. Se una system call diventa obsoleta, il suo numero rimane riservato per sempre, e chiamarla ritorna semplicemente un errore (tipicamente ENOSYS - “Function not implemented”).

Potete vedere la lista completa di tutte le system call disponibili sul vostro sistema guardando il file /usr/include/asm/unistd_64.h (su x86-64) o usando comandi come ausyscall --dump se avete installato il pacchetto auditd.

Classificazione delle System Call

Le system call possono essere organizzate in diverse categorie funzionali:

1. Gestione Processi (Process Management)

Queste system call permettono di creare, controllare e terminare processi. Sono alla base di qualsiasi programma che necessiti di eseguire task in parallelo o lanciare altri programmi.

System Call Principali:

// Creazione processi
pid_t fork(void);                    // Crea processo figlio
pid_t vfork(void);                   // Fork ottimizzato per exec
int clone(int (*fn)(void *), ...);   // Creazione thread/processo flessibile

// Esecuzione programmi
int execve(const char *pathname, char *const argv[], 
           char *const envp[]);      // Esegue nuovo programma

// Terminazione
void exit(int status);               // Termina processo
void _exit(int status);              // Termina senza cleanup

// Attesa
pid_t wait(int *status);            // Attende figlio
pid_t waitpid(pid_t pid, int *status, int options);

// Informazioni processo
pid_t getpid(void);                 // PID corrente
pid_t getppid(void);                // PID del padre
uid_t getuid(void);                 // User ID reale
uid_t geteuid(void);                // User ID effettivo

Spiegazione delle system call:

Esempio completo di utilizzo:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
    // fork() crea un nuovo processo
    pid_t pid = fork();
    
    // Controlliamo il valore di ritorno di fork()
    if (pid == -1) {
        // fork() ha fallito - possibili motivi:
        // - Limite massimo processi raggiunto
        // - Memoria insufficiente
        // - Altri limiti di sistema
        perror("fork failed");
        exit(EXIT_FAILURE);
    }
    
    if (pid == 0) {
        // ===== CODICE DEL PROCESSO FIGLIO =====
        // Qui pid == 0, quindi siamo nel figlio
        
        // getpid() ritorna il PID del processo corrente (figlio)
        // getppid() ritorna il PID del padre
        printf("Figlio: PID = %d, PPID = %d\n", getpid(), getppid());
        
        // getuid() ritorna lo User ID reale (chi ha lanciato il programma)
        // geteuid() ritorna lo User ID effettivo (per permessi)
        // Normalmente sono uguali, a meno che il programma non sia setuid
        printf("Figlio: UID = %d, EUID = %d\n", getuid(), geteuid());
        
        // Il figlio termina con exit code 42
        // Questo valore sarà disponibile al padre tramite wait()
        exit(42);
        
        // NOTA: Codice dopo exit() non viene mai eseguito
        printf("Questa riga non verrà mai stampata!\n");
        
    } else {
        // ===== CODICE DEL PROCESSO PADRE =====
        // Qui pid > 0, contiene il PID del figlio appena creato
        
        printf("Padre: PID = %d, figlio PID = %d\n", getpid(), pid);
        
        // Il padre attende la terminazione del figlio
        int status;  // Variabile per ricevere lo status di terminazione
        
        // wait() blocca fino a che un figlio termina
        // Ritorna il PID del figlio terminato
        pid_t terminated = wait(&status);
        
        // Verifichiamo come è terminato il figlio
        if (WIFEXITED(status)) {
            // Il figlio è terminato normalmente (con exit() o return)
            
            // WEXITSTATUS estrae l'exit code (42 nel nostro caso)
            int exit_code = WEXITSTATUS(status);
            
            printf("Figlio %d terminato con status %d\n", 
                   terminated, exit_code);
        } else if (WIFSIGNALED(status)) {
            // Il figlio è stato terminato da un segnale
            
            // WTERMSIG estrae il numero del segnale
            int signal = WTERMSIG(status);
            
            printf("Figlio %d terminato da segnale %d\n", 
                   terminated, signal);
        }
    }
    
    // Il padre esce con successo
    return 0;
}

Analisi dettagliata del codice:

Linea: pid_t pid = fork();

Linea: if (pid == -1)

Blocco figlio: if (pid == 0)

Linea: exit(42)

Blocco padre: else

Linea: pid_t terminated = wait(&status);

Linea: if (WIFEXITED(status))

Linea: int exit_code = WEXITSTATUS(status);

Output tipico del programma:

Padre: PID = 1234, figlio PID = 1235
Figlio: PID = 1235, PPID = 1234
Figlio: UID = 1000, EUID = 1000
Figlio 1235 terminato con status 42

Ordine non deterministico:
L’ordine delle stampe non è garantito! Potremmo vedere:

Figlio: PID = 1235, PPID = 1234
Padre: PID = 1234, figlio PID = 1235
Figlio: UID = 1000, EUID = 1000
Figlio 1235 terminato con status 42

Questo perché padre e figlio eseguono in parallelo, e lo scheduler del kernel decide chi esegue per primo. Questo è un concetto fondamentale della programmazione concorrente!

2. Gestione File (File Management)

Le system call per la gestione file sono tra le più usate in programmazione di sistema. Permettono di aprire, leggere, scrivere, e manipolare file sul filesystem.

System Call Principali:

// Apertura e chiusura
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
int close(int fd);

// Lettura e scrittura
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);

// Posizionamento
off_t lseek(int fd, off_t offset, int whence);

// Duplicazione file descriptor
int dup(int oldfd);
int dup2(int oldfd, int newfd);

// Informazioni file
int stat(const char *pathname, struct stat *statbuf);
int fstat(int fd, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);

// Controllo file
int fcntl(int fd, int cmd, ... /* arg */);
int ioctl(int fd, unsigned long request, ...);

// Sincronizzazione
int fsync(int fd);
int fdatasync(int fd);
void sync(void);

Spiegazione dettagliata delle system call:

open(pathname, flags) / open(pathname, flags, mode)

close(fd)

read(fd, buf, count)

write(fd, buf, count)

lseek(fd, offset, whence)

stat(pathname, statbuf) / fstat(fd, statbuf) / lstat(pathname, statbuf)

fsync(fd)

Esempio completo di gestione file:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>      // per open, O_* flags
#include <unistd.h>     // per read, write, close
#include <string.h>     // per strlen
#include <sys/stat.h>   // per fstat, struct stat

int main() {
    const char *filename = "esempio.txt";
    const char *data = "Hello, System Calls!\n";
    
    // ===========================================
    // 1. Creazione/Apertura file per SCRITTURA
    // ===========================================
    
    // Flags usati:
    // O_WRONLY: apri in sola scrittura
    // O_CREAT: crea il file se non esiste
    // O_TRUNC: se esiste, tronca a lunghezza 0 (cancella contenuto)
    // Mode 0644: rw-r--r-- (owner può leggere/scrivere, altri solo leggere)
    int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    
    if (fd == -1) {
        // open() ha fallito - possibili motivi:
        // EACCES: permessi insufficienti
        // ENOENT: directory nel path non esiste
        // EMFILE: troppi file aperti nel processo
        // ENFILE: troppi file aperti nel sistema
        perror("open");
        exit(EXIT_FAILURE);
    }
    
    // fd ora contiene il file descriptor (tipicamente 3)
    // I fd 0, 1, 2 sono riservati per stdin, stdout, stderr
    printf("File aperto, fd = %d\n", fd);
    
    // ===========================================
    // 2. Scrittura nel file
    // ===========================================
    
    // Scriviamo la stringa nel file
    // strlen(data) calcola la lunghezza (21 byte, incluso \n)
    ssize_t bytes_written = write(fd, data, strlen(data));
    
    if (bytes_written == -1) {
        // write() ha fallito - possibili motivi:
        // ENOSPC: disco pieno
        // EDQUOT: quota disco superata
        // EIO: errore I/O hardware
        // EINTR: interrotto da segnale
        perror("write");
        close(fd);  // Chiudiamo fd prima di uscire
        exit(EXIT_FAILURE);
    }
    
    // bytes_written potrebbe essere < strlen(data) in caso di scrittura parziale
    // In codice production, bisognerebbe gestire questo caso con un loop
    printf("Scritti %zd bytes\n", bytes_written);
    
    // ===========================================
    // 3. Sincronizzazione (flush su disco)
    // ===========================================
    
    // fsync() forza la scrittura fisica su disco
    // Senza fsync(), i dati potrebbero essere solo nel buffer cache
    // e perdersi in caso di crash/power loss
    if (fsync(fd) == -1) {
        perror("fsync");
        // Non usciamo, non è fatale, ma i dati potrebbero non essere persistenti
    }
    
    printf("Dati sincronizzati su disco\n");
    
    // ===========================================
    // 4. Ottenimento informazioni file
    // ===========================================
    
    // fstat opera su un fd già aperto
    struct stat file_stat;
    
    if (fstat(fd, &file_stat) == -1) {
        perror("fstat");
    } else {
        // file_stat ora contiene tutte le informazioni sul file
        
        // st_size: dimensione in byte
        printf("Dimensione file: %ld bytes\n", file_stat.st_size);
        
        // st_mode contiene tipo file e permessi
        // & 0777 estrae solo i permessi (ignora tipo file)
        // %o stampa in ottale (es. 644)
        printf("Permessi: %o\n", file_stat.st_mode & 0777);
        
        // st_ino: numero inode (identificatore univoco nel filesystem)
        printf("Inode: %lu\n", file_stat.st_ino);
        
        // Altri campi disponibili:
        // file_stat.st_uid: user ID proprietario
        // file_stat.st_gid: group ID
        // file_stat.st_mtime: timestamp ultima modifica
        // file_stat.st_atime: timestamp ultimo accesso
        // file_stat.st_ctime: timestamp ultimo cambio stato
        // file_stat.st_nlink: numero hard link
        // file_stat.st_dev: device ID
        // file_stat.st_blksize: dimensione blocco ottimale per I/O
        // file_stat.st_blocks: numero blocchi allocati
    }
    
    // ===========================================
    // 5. Chiusura file
    // ===========================================
    
    if (close(fd) == -1) {
        perror("close");
        exit(EXIT_FAILURE);
    }
    
    printf("File chiuso\n");
    
    // Dopo close(), fd non è più valido
    // Tentare di usare fd ora causerebbe EBADF (bad file descriptor)
    
    // ===========================================
    // 6. Riapertura per LETTURA
    // ===========================================
    
    // Ora apriamo lo stesso file in sola lettura
    fd = open(filename, O_RDONLY);
    
    if (fd == -1) {
        perror("open for reading");
        exit(EXIT_FAILURE);
    }
    
    printf("File riaperto per lettura, fd = %d\n", fd);
    
    // ===========================================
    // 7. Lettura dal file
    // ===========================================
    
    // Buffer per contenere i dati letti
    // -1 per lasciare spazio al null terminator
    char buffer[100];
    
    // Leggiamo dal file
    // read() può ritornare meno di sizeof(buffer)-1 byte
    ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);
    
    if (bytes_read == -1) {
        // read() ha fallito - possibili motivi:
        // EIO: errore I/O
        // EINTR: interrotto da segnale
        // EISDIR: fd è una directory
        perror("read");
    } else if (bytes_read == 0) {
        // EOF (End Of File) - file vuoto o già letto tutto
        printf("EOF raggiunto\n");
    } else {
        // Lettura riuscita!
        
        // Aggiungiamo null terminator per trattare come stringa
        buffer[bytes_read] = '\0';
        
        // Stampiamo il contenuto
        // %s interpreta buffer come stringa C
        printf("Letto: %s", buffer);  // No \n perché buffer già contiene \n
        printf("Numero byte letti: %zd\n", bytes_read);
    }
    
    // ===========================================
    // 8. Chiusura finale
    // ===========================================
    
    close(fd);
    
    printf("Programma terminato con successo\n");
    
    return 0;
}

Output tipico del programma:

File aperto, fd = 3
Scritti 21 bytes
Dati sincronizzati su disco
Dimensione file: 21 bytes
Permessi: 644
Inode: 123456
File chiuso
File riaperto per lettura, fd = 3
Letto: Hello, System Calls!
Numero byte letti: 21
Programma terminato con successo

Dettagli importanti:

  1. File Descriptor: Sono sempre i numeri più bassi disponibili. Se chiudiamo il fd 3 e riapriamo un file, probabilmente otterremo di nuovo 3.

  2. File Offset: Ogni fd aperto ha un offset associato (posizione corrente nel file). read() e write() avanzano automaticamente questo offset.

  3. Buffering: Il kernel mantiene una cache (page cache) dei dati del file. write() scrive nella cache, non necessariamente sul disco. fsync() forza la scrittura fisica.

  4. Atomicità: open() con O_CREAT | O_EXCL è atomico - garantisce che solo un processo crei il file.

  5. Errori parziali: read() e write() possono trasferire meno byte del richiesto. Codice production deve gestire questo con loop.

Esempio di lettura robusta con loop:

// Legge esattamente count byte (o EOF o errore)
ssize_t read_full(int fd, void *buf, size_t count) {
    size_t total = 0;
    char *ptr = buf;
    
    while (total < count) {
        ssize_t n = read(fd, ptr + total, count - total);
        
        if (n == -1) {
            if (errno == EINTR) {
                // Interrotto da segnale, riprova
                continue;
            }
            // Errore reale
            return -1;
        }
        
        if (n == 0) {
            // EOF raggiunto
            break;
        }
        
        total += n;
    }
    
    return total;
}

Questo pattern gestisce correttamente:

3. Gestione Directory

System call per operazioni su directory.

// Creazione e rimozione
int mkdir(const char *pathname, mode_t mode);
int rmdir(const char *pathname);

// Cambio directory
int chdir(const char *path);
int fchdir(int fd);
char *getcwd(char *buf, size_t size);

// Lettura directory (obsolete, usare readdir())
int getdents(unsigned int fd, struct linux_dirent *dirp, 
             unsigned int count);

// Link
int link(const char *oldpath, const char *newpath);
int unlink(const char *pathname);
int symlink(const char *target, const char *linkpath);
int readlink(const char *pathname, char *buf, size_t bufsiz);

// Rinomina
int rename(const char *oldpath, const char *newpath);

Esempio di navigazione directory:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>

int main() {
    char cwd[1024];
    
    // Ottieni directory corrente
    if (getcwd(cwd, sizeof(cwd)) != NULL) {
        printf("Directory corrente: %s\n", cwd);
    } else {
        perror("getcwd");
        exit(EXIT_FAILURE);
    }
    
    // Crea una nuova directory
    const char *new_dir = "test_syscalls";
    if (mkdir(new_dir, 0755) == -1) {
        if (errno != EEXIST) {  // Ignora se già esistente
            perror("mkdir");
            exit(EXIT_FAILURE);
        }
    }
    printf("Directory '%s' creata\n", new_dir);
    
    // Cambia directory
    if (chdir(new_dir) == -1) {
        perror("chdir");
        exit(EXIT_FAILURE);
    }
    
    // Verifica cambio
    if (getcwd(cwd, sizeof(cwd)) != NULL) {
        printf("Nuova directory corrente: %s\n", cwd);
    }
    
    // Torna alla directory originale
    if (chdir("..") == -1) {
        perror("chdir back");
        exit(EXIT_FAILURE);
    }
    
    // Rimuovi directory (deve essere vuota)
    if (rmdir(new_dir) == -1) {
        perror("rmdir");
        exit(EXIT_FAILURE);
    }
    printf("Directory '%s' rimossa\n", new_dir);
    
    return 0;
}

4. Gestione Memoria (Memory Management)

System call per allocazione e gestione della memoria.

// Allocazione memoria
void *brk(void *addr);                    // Modifica program break
void *sbrk(intptr_t increment);           // Incrementa program break

// Memory mapping
void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);         // Mappa file/memoria
int munmap(void *addr, size_t length);    // Rilascia mapping

// Protezione memoria
int mprotect(void *addr, size_t len, int prot);

// Operazioni su memoria mappata
int msync(void *addr, size_t length, int flags);
int madvise(void *addr, size_t length, int advice);

// Shared memory (System V)
int shmget(key_t key, size_t size, int shmflg);
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

Esempio di memory mapping con spiegazione dettagliata:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <string.h>

int main() {
    const char *filename = "mapped_file.txt";
    const char *data = "Contenuto mappato in memoria!";
    size_t data_len = strlen(data);
    
    // ===========================================
    // 1. Crea file
    // ===========================================
    // O_RDWR: leggi e scrivi
    // O_CREAT: crea se non esiste
    // O_TRUNC: tronca se esiste
    // 0644: rw-r--r--
    int fd = open(filename, O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    
    printf("File '%s' creato/aperto, fd=%d\n", filename, fd);
    
    // ===========================================
    // 2. Estendi file alla dimensione desiderata
    // ===========================================
    // ftruncate() imposta la dimensione del file
    // È NECESSARIO prima di mappare: non si può mappare file vuoto!
    if (ftruncate(fd, data_len) == -1) {
        perror("ftruncate");
        close(fd);
        exit(EXIT_FAILURE);
    }
    
    printf("File esteso a %zu bytes\n", data_len);
    
    // ===========================================
    // 3. Mappa file in memoria
    // ===========================================
    // mmap() crea una mappatura tra memoria virtuale e file
    // Parametri:
    //   NULL: lascia kernel scegliere indirizzo
    //   data_len: dimensione mappatura
    //   PROT_READ|PROT_WRITE: permessi read+write
    //   MAP_SHARED: modifiche visibili ad altri processi e salvate su file
    //   fd: file descriptor da mappare
    //   0: offset nel file (0 = dall'inizio)
    char *mapped = mmap(NULL, data_len, PROT_READ | PROT_WRITE,
                        MAP_SHARED, fd, 0);
    
    if (mapped == MAP_FAILED) {
        perror("mmap");
        close(fd);
        exit(EXIT_FAILURE);
    }
    
    printf("File mappato in memoria all'indirizzo %p\n", (void*)mapped);
    
    // IMPORTANTE: Dopo mmap(), possiamo chiudere fd
    // La mappatura resta valida!
    close(fd);
    printf("File descriptor chiuso (mappatura ancora valida)\n");
    
    // ===========================================
    // 4. Scrivi direttamente nella memoria mappata
    // ===========================================
    // Scrivere in 'mapped' è come scrivere nel file!
    // Non serve write() - usiamo memcpy o strcpy
    memcpy(mapped, data, data_len);
    printf("Dati scritti in memoria mappata\n");
    
    // A questo punto i dati sono nel page cache del kernel
    // ma potrebbero non essere ancora sul disco fisico
    
    // ===========================================
    // 5. Sincronizza con il disco
    // ===========================================
    // msync() forza scrittura su disco
    // Parametri:
    //   mapped: indirizzo mappatura
    //   data_len: lunghezza
    //   MS_SYNC: sincrono (blocca fino a completamento)
    //   Altre opzioni: MS_ASYNC (asincrono), MS_INVALIDATE
    if (msync(mapped, data_len, MS_SYNC) == -1) {
        perror("msync");
        // Non fatale, ma dati potrebbero perdersi
    }
    
    printf("Dati sincronizzati su disco\n");
    
    // ===========================================
    // 6. Leggi dalla memoria mappata
    // ===========================================
    // Leggere da 'mapped' è come leggere dal file!
    printf("Contenuto memoria mappata: %.*s\n", (int)data_len, mapped);
    
    // ===========================================
    // 7. Modifica in-place
    // ===========================================
    // Possiamo modificare byte specifici direttamente
    mapped[0] = 'c';  // 'C' -> 'c'
    mapped[10] = 'M';  // Cambia un byte in mezzo
    
    printf("Contenuto modificato: %.*s\n", (int)data_len, mapped);
    
    // Sincronizza modifiche
    msync(mapped, data_len, MS_SYNC);
    
    // ===========================================
    // 8. Rilascia mapping
    // ===========================================
    // munmap() rilascia la mappatura
    // Dopo munmap(), l'indirizzo 'mapped' non è più valido
    if (munmap(mapped, data_len) == -1) {
        perror("munmap");
        exit(EXIT_FAILURE);
    }
    
    printf("Mapping rilasciato\n");
    
    // ===========================================
    // 9. Verifica che le modifiche persistono
    // ===========================================
    // Riapriamo il file normalmente e leggiamo
    fd = open(filename, O_RDONLY);
    if (fd != -1) {
        char verify_buf[100];
        ssize_t n = read(fd, verify_buf, sizeof(verify_buf) - 1);
        if (n > 0) {
            verify_buf[n] = '\0';
            printf("Verifica da file: %s\n", verify_buf);
        }
        close(fd);
    }
    
    printf("\n=== Vantaggi di mmap ===\n");
    printf("1. Accesso casuale efficiente (come array)\n");
    printf("2. Zero-copy: no buffer intermedi\n");
    printf("3. Shared memory: più processi vedono stessi dati\n");
    printf("4. File grandi: mappa solo porzione necessaria\n");
    printf("5. Lazy loading: pagine caricate on-demand\n");
    
    return 0;
}

Output del programma:

File 'mapped_file.txt' creato/aperto, fd=3
File esteso a 30 bytes
File mappato in memoria all'indirizzo 0x7f1234567000
File descriptor chiuso (mappatura ancora valida)
Dati scritti in memoria mappata
Dati sincronizzati su disco
Contenuto memoria mappata: Contenuto mappato in memoria!
Contenuto modificato: contenuto Mappato in memoria!
Mapping rilasciato
Verifica da file: contenuto Mappato in memoria!

=== Vantaggi di mmap ===
1. Accesso casuale efficiente (come array)
2. Zero-copy: no buffer intermedi
3. Shared memory: più processi vedono stessi dati
4. File grandi: mappa solo porzione necessaria
5. Lazy loading: pagine caricate on-demand

Quando usare mmap vs read/write:

Scenario mmap read/write
File piccoli (<100KB) ❌ Overhead ✅ Più semplice
File grandi (>10MB) ✅ Efficiente ❌ Lento
Accesso sequenziale ⚠️ Ok ✅ Ottimale
Accesso casuale ✅ Molto efficiente ❌ Molti lseek
Shared memory ✅ Ideale (MAP_SHARED) ❌ Non supportato
Semplicità codice ⚠️ Più complesso ✅ Più semplice
Portabilità ⚠️ POSIX-only ✅ Universale

5. Inter-Process Communication (IPC)

System call per la comunicazione tra processi.

Pipe e FIFO:

// Pipe
int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

// FIFO (Named Pipe)
int mkfifo(const char *pathname, mode_t mode);

Esempio completo di pipe con spiegazione dettagliata:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main() {
    // Array per contenere i due file descriptor della pipe
    int pipefd[2];
    pid_t pid;
    char buffer[100];
    
    // ===========================================
    // 1. Crea la pipe
    // ===========================================
    // pipe() crea una coppia di file descriptor collegati:
    // pipefd[0]: lato lettura (read end)
    // pipefd[1]: lato scrittura (write end)
    // I dati scritti in pipefd[1] possono essere letti da pipefd[0]
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }
    
    printf("Pipe creata: fd[0]=%d (read), fd[1]=%d (write)\n", 
           pipefd[0], pipefd[1]);
    
    // ===========================================
    // 2. Fork per creare processo figlio
    // ===========================================
    // Dopo fork(), entrambi padre e figlio hanno
    // COPIA dei due file descriptor della pipe
    pid = fork();
    
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }
    
    if (pid == 0) {
        // ===== PROCESSO FIGLIO =====
        
        printf("[FIGLIO %d] Iniziato\n", getpid());
        
        // Il figlio leggerà dalla pipe
        // Quindi chiudiamo il lato scrittura (non lo useremo)
        // Questo è IMPORTANTE: ogni end deve essere chiuso da chi non lo usa
        close(pipefd[1]);
        printf("[FIGLIO] Chiuso lato scrittura (fd=%d)\n", pipefd[1]);
        
        // Leggiamo dalla pipe (pipefd[0])
        // read() si blocca fino a che ci sono dati disponibili
        printf("[FIGLIO] In attesa di dati dalla pipe...\n");
        
        ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1);
        
        if (n == -1) {
            perror("[FIGLIO] read");
            exit(EXIT_FAILURE);
        } else if (n == 0) {
            // n == 0 significa EOF
            // Questo succede quando tutti i processi chiudono il write end
            printf("[FIGLIO] EOF ricevuto (pipe chiusa)\n");
        } else {
            // Dati ricevuti!
            buffer[n] = '\0';  // Null-terminate per printf
            printf("[FIGLIO] Ricevuti %zd bytes: '%s'\n", n, buffer);
        }
        
        // Chiudi lato lettura (abbiamo finito)
        close(pipefd[0]);
        printf("[FIGLIO] Chiuso lato lettura, termino\n");
        
        exit(EXIT_SUCCESS);
        
    } else {
        // ===== PROCESSO PADRE =====
        
        printf("[PADRE %d] Figlio creato con PID=%d\n", getpid(), pid);
        
        // Il padre scriverà nella pipe
        // Quindi chiudiamo il lato lettura (non lo useremo)
        close(pipefd[0]);
        printf("[PADRE] Chiuso lato lettura (fd=%d)\n", pipefd[0]);
        
        // Prepariamo il messaggio da inviare
        const char *msg = "Messaggio dal padre via pipe!";
        printf("[PADRE] Invio messaggio: '%s'\n", msg);
        
        // Scriviamo nella pipe (pipefd[1])
        // write() scrive i byte nel buffer della pipe
        ssize_t written = write(pipefd[1], msg, strlen(msg));
        
        if (written == -1) {
            perror("[PADRE] write");
            exit(EXIT_FAILURE);
        }
        
        printf("[PADRE] Scritti %zd bytes nella pipe\n", written);
        
        // IMPORTANTE: Chiudiamo il write end
        // Questo causa EOF nel figlio quando legge tutto
        // Se non chiudiamo, il figlio rimarrebbe bloccato in read()
        close(pipefd[1]);
        printf("[PADRE] Chiuso lato scrittura\n");
        
        // Aspettiamo che il figlio termini
        int status;
        pid_t terminated = wait(&status);
        
        if (terminated == -1) {
            perror("[PADRE] wait");
        } else {
            printf("[PADRE] Figlio %d terminato", terminated);
            if (WIFEXITED(status)) {
                printf(" con exit code %d\n", WEXITSTATUS(status));
            } else {
                printf(" anormalmente\n");
            }
        }
        
        printf("[PADRE] Termino\n");
    }
    
    return 0;
}

Output tipico del programma:

Pipe creata: fd[0]=3 (read), fd[1]=4 (write)
[PADRE 1234] Figlio creato con PID=1235
[PADRE] Chiuso lato lettura (fd=3)
[PADRE] Invio messaggio: 'Messaggio dal padre via pipe!'
[FIGLIO 1235] Iniziato
[FIGLIO] Chiuso lato scrittura (fd=4)
[FIGLIO] In attesa di dati dalla pipe...
[PADRE] Scritti 30 bytes nella pipe
[PADRE] Chiuso lato scrittura
[FIGLIO] Ricevuti 30 bytes: 'Messaggio dal padre via pipe!'
[FIGLIO] Chiuso lato lettura, termino
[PADRE] Figlio 1235 terminato con exit code 0
[PADRE] Termino

Spiegazione dettagliata del meccanismo pipe:

  1. Creazione pipe:
Prima di pipe():  nessun fd speciale

Dopo pipe():     
Padre:  fd[0]=3 (read) ←─┐
        fd[1]=4 (write)─┐ │
                        │ │ PIPE KERNEL
Figlio: (non esiste)   │ │ (buffer interno)
                        ↓ ↑
  1. Dopo fork():
Padre:  fd[0]=3 (read) ←─┐
        fd[1]=4 (write)─┐ │
                        │ │ PIPE KERNEL
Figlio: fd[0]=3 (read) ←┘ │ (STESSO buffer
        fd[1]=4 (write)───┘  condiviso!)
  1. Dopo chiusure appropriate:
Padre:  fd[0]=CHIUSO
        fd[1]=4 (write)────┐
                           │ PIPE KERNEL
Figlio: fd[0]=3 (read) ←───┤
        fd[1]=CHIUSO       │
                           
Comunicazione unidirezionale: Padre → Figlio

Perché chiudere i file descriptor non usati?

  1. EOF corretto: Se padre non chiude write end dopo aver scritto,
    figlio non riceverà mai EOF e rimarrà bloccato in read()

  2. Resource leak: File descriptor non chiusi consumano risorse

  3. Segnali corretti: Se tutti i write end sono chiusi e si tenta write(),
    si riceve SIGPIPE (desiderato per rilevare pipe rotte)

Pipe bidirezionali (necessitano 2 pipe):

int pipe1[2], pipe2[2];  // Due pipe
pipe(pipe1);  // Padre → Figlio
pipe(pipe2);  // Figlio → Padre

if (fork() == 0) {
    // Figlio
    close(pipe1[1]);  // Chiude write end di pipe1
    close(pipe2[0]);  // Chiude read end di pipe2
    
    // Legge da pipe1, scrive in pipe2
    read(pipe1[0], ...);
    write(pipe2[1], ...);
    
} else {
    // Padre
    close(pipe1[0]);  // Chiude read end di pipe1
    close(pipe2[1]);  // Chiude write end di pipe2
    
    // Scrive in pipe1, legge da pipe2
    write(pipe1[1], ...);
    read(pipe2[0], ...);
}

Limitazioni delle pipe:

  1. Unidirezionali (serve pipe separata per bidirezionale)
  2. Solo tra processi con antenato comune (usa named pipe/FIFO per altro)
  3. Capacità limitata (tipicamente 64KB su Linux) - write si blocca se piena
  4. Dati non strutturati (stream di byte, no message boundaries)
  5. Non persistenti (spariscono quando processi terminano)

Vantaggi delle pipe:

  1. Semplici da usare
  2. Efficienti (zero-copy nel kernel)
  3. Sicure (isolamento tra processi)
  4. Standard (POSIX, portabili)
  5. Atomicità (write <PIPE_BUF garantito atomico)

PIPE_BUF:

#include <limits.h>
printf("PIPE_BUF = %d bytes\n", PIPE_BUF);
// Tipicamente 4096 su Linux
// Write di dimensione ≤ PIPE_BUF sono atomiche

Message Queue, Semafori, Shared Memory (System V IPC):

// Message Queue
int msgget(key_t key, int msgflg);
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,
               int msgflg);
int msgctl(int msqid, int cmd, struct msqid_ds *buf);

// Semafori
int semget(key_t key, int nsems, int semflg);
int semop(int semid, struct sembuf *sops, size_t nsops);
int semctl(int semid, int semnum, int cmd, ...);

// Shared Memory (già visto sopra)

Socket:

// Creazione socket
int socket(int domain, int type, int protocol);

// Bind e listen (server)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
int listen(int sockfd, int backlog);
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

// Connect (client)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

// Invio e ricezione
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

6. Gestione Segnali (Signal Management)

I segnali sono il meccanismo Unix per gestire eventi asincroni: interruzioni, errori, timer, o comunicazione tra processi. Sono simili agli interrupt hardware, ma a livello software.

System call per gestire segnali:

// Handler segnali
sighandler_t signal(int signum, sighandler_t handler);
int sigaction(int signum, const struct sigaction *act,
              struct sigaction *oldact);

// Invio segnali
int kill(pid_t pid, int sig);
int raise(int sig);
int sigqueue(pid_t pid, int sig, const union sigval value);

// Signal mask
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigsuspend(const sigset_t *mask);

// Attesa segnali
int pause(void);
int sigwaitinfo(const sigset_t *set, siginfo_t *info);
int sigtimedwait(const sigset_t *set, siginfo_t *info,
                 const struct timespec *timeout);

Spiegazione delle system call:

signal(signum, handler)

sigaction(signum, act, oldact)

kill(pid, sig)

raise(sig)

sigprocmask(how, set, oldset)

pause()

Segnali comuni:

Segnale Numero Significato Default
SIGHUP 1 Hangup (terminale chiuso) Termina
SIGINT 2 Interrupt (Ctrl+C) Termina
SIGQUIT 3 Quit (Ctrl+\) Termina + core
SIGILL 4 Istruzione illegale Termina + core
SIGABRT 6 Abort Termina + core
SIGFPE 8 Floating point exception Termina + core
SIGKILL 9 Kill (non catturabile) Termina
SIGSEGV 11 Segmentation fault Termina + core
SIGPIPE 13 Pipe rotta Termina
SIGALRM 14 Alarm timer Termina
SIGTERM 15 Terminazione (default kill) Termina
SIGCHLD 17 Figlio terminato Ignora
SIGCONT 18 Continua Continua
SIGSTOP 19 Stop (non catturabile) Stop
SIGTSTP 20 Stop da terminale (Ctrl+Z) Stop

Esempio completo di gestione segnali:

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

// Variabile globale modificata dall'handler
// sig_atomic_t garantisce accesso atomico
volatile sig_atomic_t signal_received = 0;

// Handler per SIGINT (Ctrl+C)
void sigint_handler(int signum) {
    // IMPORTANTE: Gli handler devono essere "async-signal-safe"
    // Non tutte le funzioni sono sicure dentro un handler!
    
    // Safe: modificare variabili sig_atomic_t
    signal_received = 1;
    
    // write() è async-signal-safe, printf() NO!
    // Usiamo write() invece di printf()
    const char msg[] = "\nSIGINT ricevuto!\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
    
    // NOTA: L'handler ritorna automaticamente,
    // e l'esecuzione riprende dal punto in cui era stata interrotta
    
    // Per SIGINT, il comportamento default è terminare il processo
    // Ma siccome abbiamo installato il nostro handler, 
    // il processo continua l'esecuzione!
}

// Handler per SIGTERM
void sigterm_handler(int signum) {
    const char msg[] = "SIGTERM ricevuto, termino...\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
    
    // Termina il processo
    // Possiamo fare cleanup qui se necessario
    exit(EXIT_SUCCESS);
}

// Handler per SIGALRM (timer)
void sigalrm_handler(int signum) {
    const char msg[] = "SIGALRM: timer scaduto!\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}

int main() {
    // Struttura per configurare l'azione del segnale
    struct sigaction sa_int, sa_term, sa_alrm;
    
    // ===========================================
    // Configura handler per SIGINT (Ctrl+C)
    // ===========================================
    
    // Inizializza la struttura
    // sa_handler: puntatore alla funzione handler
    sa_int.sa_handler = sigint_handler;
    
    // Inizializza set di segnali vuoto
    // Questi segnali saranno bloccati durante l'esecuzione dell'handler
    sigemptyset(&sa_int.sa_mask);
    
    // Flag: 0 significa comportamento default
    // Altri flag comuni:
    // SA_RESTART: riavvia automaticamente syscall interrotte
    // SA_SIGINFO: usa sa_sigaction invece di sa_handler
    // SA_NODEFER: non bloccare il segnale durante l'handler
    sa_int.sa_flags = 0;
    
    // Installa l'handler
    // NULL come terzo parametro = non ci interessa la vecchia azione
    if (sigaction(SIGINT, &sa_int, NULL) == -1) {
        perror("sigaction SIGINT");
        exit(EXIT_FAILURE);
    }
    
    printf("Handler SIGINT installato\n");
    
    // ===========================================
    // Configura handler per SIGTERM
    // ===========================================
    
    sa_term.sa_handler = sigterm_handler;
    sigemptyset(&sa_term.sa_mask);
    sa_term.sa_flags = 0;
    
    if (sigaction(SIGTERM, &sa_term, NULL) == -1) {
        perror("sigaction SIGTERM");
        exit(EXIT_FAILURE);
    }
    
    printf("Handler SIGTERM installato\n");
    
    // ===========================================
    // Configura handler per SIGALRM
    // ===========================================
    
    sa_alrm.sa_handler = sigalrm_handler;
    sigemptyset(&sa_alrm.sa_mask);
    sa_alrm.sa_flags = 0;
    
    if (sigaction(SIGALRM, &sa_alrm, NULL) == -1) {
        perror("sigaction SIGALRM");
        exit(EXIT_FAILURE);
    }
    
    printf("Handler SIGALRM installato\n");
    
    // ===========================================
    // Imposta un alarm
    // ===========================================
    
    // alarm() invia SIGALRM dopo N secondi
    // Ritorna il numero di secondi rimanenti del precedente alarm (0 se nessuno)
    unsigned int prev_alarm = alarm(5);
    printf("Alarm impostato a 5 secondi (precedente: %u)\n", prev_alarm);
    
    // ===========================================
    // Informazioni per l'utente
    // ===========================================
    
    printf("\n=== Comandi disponibili ===\n");
    printf("PID del processo: %d\n", getpid());
    printf("Premi Ctrl+C per inviare SIGINT\n");
    printf("Da altro terminale, usa: kill %d (SIGTERM)\n", getpid());
    printf("                    o: kill -SIGINT %d\n", getpid());
    printf("Alarm SIGALRM scatterà tra 5 secondi\n");
    printf("===========================\n\n");
    
    // ===========================================
    // Loop infinito (il programma resta in esecuzione)
    // ===========================================
    
    int count = 0;
    while (1) {
        // Controlla se abbiamo ricevuto SIGINT
        if (signal_received) {
            printf("Main loop: SIGINT gestito, continuo l'esecuzione...\n");
            
            // Reset del flag
            signal_received = 0;
            
            // Potremmo decidere di uscire dopo N SIGINT
            count++;
            if (count >= 3) {
                printf("Ricevuti 3 SIGINT, esco.\n");
                break;
            }
        }
        
        // Stampa un messaggio periodico per mostrare che siamo vivi
        printf(".");
        fflush(stdout);  // Forza stampa immediata
        
        // Dormi per 1 secondo
        // sleep() può essere interrotta da segnali
        unsigned int remaining = sleep(1);
        
        if (remaining > 0) {
            // sleep interrotta (probabilmente da segnale)
            // remaining contiene i secondi non dormiti
            // (non stampiamo nulla, continuiamo il loop)
        }
    }
    
    printf("\nProgramma terminato normalmente\n");
    return 0;
}

Compilazione ed esecuzione:

gcc -o signals signals.c
./signals

Output esempio:

Handler SIGINT installato
Handler SIGTERM installato
Handler SIGALRM installato
Alarm impostato a 5 secondi (precedente: 0)

=== Comandi disponibili ===
PID del processo: 12345
Premi Ctrl+C per inviare SIGINT
Da altro terminale, usa: kill 12345 (SIGTERM)
                    o: kill -SIGINT 12345
Alarm SIGALRM scatterà tra 5 secondi
===========================

....
^C
SIGINT ricevuto!
Main loop: SIGINT gestito, continuo l'esecuzione...
.....SIGALRM: timer scaduto!
....
^C
SIGINT ricevuto!
Main loop: SIGINT gestito, continuo l'esecuzione...
.....
^C
SIGINT ricevuto!
Main loop: SIGINT gestito, continuo l'esecuzione...
Ricevuti 3 SIGINT, esco.

Programma terminato normalmente

Spiegazione dettagliata:

  1. volatile sig_atomic_t signal_received:

  2. Handler async-signal-safe:

  3. sigemptyset(&sa_int.sa_mask):

  4. sa_flags = 0:

  5. alarm(5):

  6. Blocco e sblocco segnali:

// Esempio di blocco temporaneo di segnali
sigset_t set, oldset;

// Inizializza set vuoto
sigemptyset(&set);

// Aggiungi SIGINT al set
sigaddset(&set, SIGINT);

// Blocca SIGINT (viene aggiunto alla maschera corrente)
sigprocmask(SIG_BLOCK, &set, &oldset);

// Sezione critica - SIGINT non verrà consegnato qui
// (resta pending se arriva)
printf("SIGINT bloccato in questa sezione\n");
sleep(5);

// Ripristina maschera precedente (sblocca SIGINT)
sigprocmask(SIG_SETMASK, &oldset, NULL);

// Se SIGINT era pending, viene consegnato ORA

Casi d’uso reali dei segnali:

  1. Graceful shutdown: SIGTERM per cleanup ordinato
  2. Reload configurazione: SIGHUP per ricaricare config senza restart
  3. Gestione figli: SIGCHLD per evitare processi zombie
  4. Timeout: SIGALRM per operazioni con timeout
  5. Debug: SIGUSR1/SIGUSR2 per dump stato interno
  6. Ctrl+C handling: SIGINT per interrupt user-friendly

7. Gestione Tempo (Time Management)

System call per operazioni temporali.

// Tempo corrente
time_t time(time_t *tloc);
int gettimeofday(struct timeval *tv, struct timezone *tz);
int clock_gettime(clockid_t clockid, struct timespec *tp);

// Impostazione tempo (richiede privilegi)
int settimeofday(const struct timeval *tv, const struct timezone *tz);
int clock_settime(clockid_t clockid, const struct timespec *tp);

// Sleep
unsigned int sleep(unsigned int seconds);
int nanosleep(const struct timespec *req, struct timespec *rem);
int clock_nanosleep(clockid_t clockid, int flags,
                    const struct timespec *request,
                    struct timespec *remain);

// Timer
int timer_create(clockid_t clockid, struct sigevent *sevp,
                 timer_t *timerid);
int timer_settime(timer_t timerid, int flags,
                  const struct itimerspec *new_value,
                  struct itimerspec *old_value);
int timer_gettime(timer_t timerid, struct itimerspec *curr_value);
int timer_delete(timer_t timerid);

// Allarmi
unsigned int alarm(unsigned int seconds);
int setitimer(int which, const struct itimerval *new_value,
              struct itimerval *old_value);

Esempio di misurazione tempo:

#include <stdio.h>
#include <time.h>
#include <unistd.h>

void operazione_costosa() {
    // Simula operazione che richiede tempo
    usleep(500000);  // 500ms
}

int main() {
    struct timespec start, end;
    
    // Tempo di sistema (wall-clock time)
    clock_gettime(CLOCK_REALTIME, &start);
    printf("Inizio: %ld.%09ld\n", start.tv_sec, start.tv_nsec);
    
    operazione_costosa();
    
    clock_gettime(CLOCK_REALTIME, &end);
    printf("Fine: %ld.%09ld\n", end.tv_sec, end.tv_nsec);
    
    // Calcola tempo trascorso
    long sec_diff = end.tv_sec - start.tv_sec;
    long nsec_diff = end.tv_nsec - start.tv_nsec;
    
    if (nsec_diff < 0) {
        sec_diff--;
        nsec_diff += 1000000000L;
    }
    
    printf("Tempo trascorso: %ld.%09ld secondi\n", sec_diff, nsec_diff);
    
    // CPU time del processo
    struct timespec cpu_time;
    clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &cpu_time);
    printf("CPU time del processo: %ld.%09ld secondi\n", 
           cpu_time.tv_sec, cpu_time.tv_nsec);
    
    return 0;
}

8. I/O Multiplexing

System call per monitorare multipli file descriptor.

// select
int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

// poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

// epoll (Linux-specific, più efficiente)
int epoll_create(int size);
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);

Esempio con select:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>

int main() {
    fd_set readfds;
    struct timeval tv;
    int retval;
    char buffer[100];
    
    printf("Aspetto input da stdin (timeout 5 secondi)...\n");
    
    // Inizializza set di file descriptor
    FD_ZERO(&readfds);
    FD_SET(STDIN_FILENO, &readfds);
    
    // Imposta timeout
    tv.tv_sec = 5;
    tv.tv_usec = 0;
    
    // Monitora stdin
    retval = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);
    
    if (retval == -1) {
        perror("select");
        exit(EXIT_FAILURE);
    } else if (retval) {
        printf("Dati disponibili su stdin\n");
        if (FD_ISSET(STDIN_FILENO, &readfds)) {
            ssize_t n = read(STDIN_FILENO, buffer, sizeof(buffer) - 1);
            if (n > 0) {
                buffer[n] = '\0';
                printf("Letto: %s", buffer);
            }
        }
    } else {
        printf("Timeout! Nessun dato ricevuto in 5 secondi.\n");
    }
    
    return 0;
}

9. System Call di Sistema

Informazioni e controllo del sistema.

// Informazioni sistema
int uname(struct utsname *buf);
int sysinfo(struct sysinfo *info);

// Reboot (richiede privilegi root)
int reboot(int magic, int magic2, int cmd, void *arg);

// Montaggio filesystem (richiede privilegi)
int mount(const char *source, const char *target,
          const char *filesystemtype, unsigned long mountflags,
          const void *data);
int umount(const char *target);
int umount2(const char *target, int flags);

// Controllo risorse
int getrlimit(int resource, struct rlimit *rlim);
int setrlimit(int resource, const struct rlimit *rlim);

// Priorità processi
int nice(int inc);
int getpriority(int which, id_t who);
int setpriority(int which, id_t who, int prio);

// Hostname
int gethostname(char *name, size_t len);
int sethostname(const char *name, size_t len);

Esempio informazioni sistema:

#include <stdio.h>
#include <stdlib.h>
#include <sys/utsname.h>
#include <sys/sysinfo.h>
#include <sys/resource.h>
#include <unistd.h>

int main() {
    struct utsname uts;
    struct sysinfo si;
    struct rlimit rlim;
    
    // Informazioni sul sistema operativo
    if (uname(&uts) == 0) {
        printf("=== Informazioni Sistema ===\n");
        printf("Sistema operativo: %s\n", uts.sysname);
        printf("Nome nodo: %s\n", uts.nodename);
        printf("Release: %s\n", uts.release);
        printf("Versione: %s\n", uts.version);
        printf("Architettura: %s\n\n", uts.machine);
    }
    
    // Statistiche sistema
    if (sysinfo(&si) == 0) {
        printf("=== Statistiche Sistema ===\n");
        printf("Uptime: %ld secondi\n", si.uptime);
        printf("RAM totale: %lu MB\n", si.totalram / (1024 * 1024));
        printf("RAM libera: %lu MB\n", si.freeram / (1024 * 1024));
        printf("Numero processi: %d\n\n", si.procs);
    }
    
    // Limiti risorse processo
    if (getrlimit(RLIMIT_NOFILE, &rlim) == 0) {
        printf("=== Limiti Risorse ===\n");
        printf("Max file descriptor aperti:\n");
        printf("  Soft limit: %ld\n", (long)rlim.rlim_cur);
        printf("  Hard limit: %ld\n\n", (long)rlim.rlim_max);
    }
    
    // Priorità processo
    int prio = getpriority(PRIO_PROCESS, 0);
    printf("Priorità corrente (nice): %d\n", prio);
    
    // Hostname
    char hostname[256];
    if (gethostname(hostname, sizeof(hostname)) == 0) {
        printf("Hostname: %s\n", hostname);
    }
    
    return 0;
}

Gestione degli Errori

La gestione corretta degli errori è fondamentale nella programmazione di sistema. Le system call, a differenza delle funzioni normali, possono fallire per svariati motivi e l’applicazione deve essere preparata a gestire ogni situazione.

Il Ruolo di errno

La variabile globale errno è il meccanismo principale per comunicare errori dalle system call alle applicazioni. Ecco come funziona:

Comportamento delle system call in caso di errore:

  1. Valore di ritorno: La system call ritorna un valore che indica errore (tipicamente -1, ma varia)
  2. Impostazione errno: La libreria C (o direttamente il kernel) imposta errno a un codice di errore specifico
  3. errno non viene azzerato: Se una system call ha successo, errno NON viene modificato
#include <errno.h>      // per errno
#include <string.h>     // per strerror()
#include <stdio.h>      // per perror()
#include <fcntl.h>      // per open()
#include <unistd.h>     // per close()

int main() {
    // Tentiamo di aprire un file che probabilmente non esiste
    int fd = open("/file_inesistente", O_RDONLY);
    
    // Controlliamo il valore di ritorno
    if (fd == -1) {
        // open() ha fallito, errno è stato impostato
        
        // METODO 1: Stampare il numero di errno
        printf("Errore numero: %d\n", errno);
        // Output: "Errore numero: 2"
        
        // METODO 2: Convertire errno in stringa descrittiva
        printf("Descrizione: %s\n", strerror(errno));
        // Output: "Descrizione: No such file or directory"
        
        // METODO 3: Usare perror() - più semplice
        perror("open");
        // Output: "open: No such file or directory"
        
        // perror() è equivalente a:
        // fprintf(stderr, "open: %s\n", strerror(errno));
        
        return 1;  // Exit con errore
    }
    
    // Se arriviamo qui, fd è valido (>= 0)
    printf("File aperto con successo, fd = %d\n", fd);
    close(fd);
    
    return 0;
}

Dettagli importanti su errno:

  1. Non azzerato su successo:
errno = 0;  // Reset manuale
int fd = open("file_esistente", O_RDONLY);  // Successo
// errno potrebbe ancora contenere vecchio valore!

// CORRETTO: Controlla sempre il valore di ritorno PRIMA di guardare errno
if (fd == -1) {
    // SOLO ora errno è affidabile
    if (errno == ENOENT) { /* ... */ }
}
  1. Può essere modificato da altre chiamate:
int fd = open("file", O_RDONLY);
if (fd == -1) {
    int saved_errno = errno;  // Salva subito!
    
    printf("Debug info...\n");  // printf potrebbe modificare errno!
    
    // Usa saved_errno invece di errno
    fprintf(stderr, "Errore: %s\n", strerror(saved_errno));
}
  1. Thread-safe nei sistemi moderni:
// errno è in realtà una macro che espande a:
#define errno (*__errno_location())

// Ogni thread ha la sua copia di errno
// Thread A può impostare errno senza interferire con Thread B

Codici di Errore Comuni

La header <errno.h> definisce decine di codici di errore. Ecco i più comuni:

#include <errno.h>

// ===== ERRORI GENERALI =====

EPERM           1      // Operation not permitted
// Operazione richiede privilegi che il processo non ha
// Esempio: tentare di cambiare owner di un file non proprio

ENOENT          2      // No such file or directory
// File o directory specificata non esiste
// Esempio: open("/file/inesistente", ...)

ESRCH           3      // No such process
// PID specificato non esiste
// Esempio: kill(99999, SIGTERM) dove 99999 non è un PID valido

EINTR           4      // Interrupted system call
// System call interrotta da un segnale
// Molte syscall possono essere interrotte (read, write, wait, etc.)
// Spesso si deve ritentare

EIO             5      // I/O error
// Errore fisico di I/O (disco corrotto, network down, etc.)
// Grave - i dati potrebbero essere persi

ENXIO           6      // No such device or address
// Device specificato non esiste
// Esempio: aprire /dev/dispositivo_inesistente

E2BIG           7      // Argument list too long
// Troppi argomenti passati a exec()
// Lista argomenti > ARG_MAX (tipicamente 128KB)

EBADF           9      // Bad file descriptor
// File descriptor non valido (non aperto o già chiuso)
// Bug comune: usare fd dopo close()

EAGAIN         11      // Try again / Resource temporarily unavailable
// Risorsa temporaneamente non disponibile
// Non è un errore fatale - ritentare
// Esempio: socket non-blocking senza dati disponibili
// NOTA: EWOULDBLOCK è spesso uguale a EAGAIN

ENOMEM         12      // Out of memory
// Memoria insufficiente per completare l'operazione
// Sistema sotto stress di memoria

EACCES         13      // Permission denied
// Permessi insufficienti
// Esempio: tentare di aprire /etc/shadow senza essere root

EFAULT         14      // Bad address
// Puntatore passato alla syscall non valido
// Esempio: read(fd, NULL, 100) - NULL non è un indirizzo valido

ENOTBLK        15      // Block device required
// Operazione richiede block device, ma è stato dato altro

EBUSY          16      // Device or resource busy
// Risorsa in uso, non può essere acceduta ora
// Esempio: tentare di unmount filesystem in uso

EEXIST         17      // File exists
// File già esiste quando dovrebbe essere nuovo
// Esempio: open("file", O_CREAT | O_EXCL) su file esistente

EXDEV          18      // Cross-device link
// Tentativo di creare hard link attraverso filesystem diversi

ENODEV         19      // No such device
// Device specificato non esiste

ENOTDIR        20      // Not a directory
// Componente del path è un file, non una directory
// Esempio: open("/etc/passwd/subdir/file", ...) - passwd è un file!

EISDIR         21      // Is a directory
// Operazione valida solo su file, ma è stata data directory
// Esempio: open("/etc", O_WRONLY) - non si può aprire directory in scrittura

EINVAL         22      // Invalid argument
// Argomento non valido
// Esempio: lseek(fd, 0, 999) - 999 non è un whence valido

ENFILE         23      // File table overflow
// Troppi file aperti nel sistema (limite globale)

EMFILE         24      // Too many open files
// Troppi file aperti in questo processo (limite per-processo)
// Controllare con: ulimit -n

ENOTTY         25      // Not a typewriter (Not a terminal)
// Operazione ioctl non valida per questo tipo di dispositivo

ETXTBSY        26      // Text file busy
// File eseguibile aperto per scrittura
// Non si può eseguire un file mentre è aperto in scrittura

EFBIG          27      // File too large
// File supera limite massimo
// Controllare con: ulimit -f

ENOSPC         28      // No space left on device
// Filesystem pieno
// Errore comune - disco pieno!

ESPIPE         29      // Illegal seek
// lseek() su pipe o socket (non supportato)

EROFS          30      // Read-only file system
// Tentativo di modifica su filesystem read-only

EMLINK         31      // Too many links
// Troppi hard link al file

EPIPE          32      // Broken pipe
// Scrittura su pipe senza lettore
// Genera anche SIGPIPE per default

ERANGE         34      // Math result not representable
// Risultato fuori range

// ===== ERRORI NETWORK =====

EADDRINUSE     98      // Address already in use
// Tentativo di bind() su indirizzo già in uso
// Esempio: due server sulla stessa porta

ECONNREFUSED   111     // Connection refused
// Server non accetta connessioni
// Esempio: connect() a porta dove nessuno ascolta

ETIMEDOUT      110     // Connection timed out
// Operazione network scaduta (timeout)

// ===== ERRORI DEADLOCK =====

EDEADLK        35      // Resource deadlock would occur
// Operazione causerebbe deadlock
// Sistema ha rilevato potenziale deadlock e lo previene

// ===== ERRORI NOME FILE =====

ENAMETOOLONG   36      // File name too long
// Nome file o path troppo lungo
// PATH_MAX tipicamente 4096, NAME_MAX tipicamente 255

Come interpretare errno:

#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>

void open_with_error_analysis(const char *path) {
    int fd = open(path, O_RDONLY);
    
    if (fd == -1) {
        // Analisi dettagliata dell'errore
        switch (errno) {
            case ENOENT:
                fprintf(stderr, "File '%s' non esiste\n", path);
                fprintf(stderr, "Suggerimento: Verifica il path\n");
                break;
                
            case EACCES:
                fprintf(stderr, "Permesso negato per '%s'\n", path);
                fprintf(stderr, "Suggerimento: Controlla permessi con ls -l\n");
                break;
                
            case EMFILE:
                fprintf(stderr, "Troppi file aperti in questo processo\n");
                fprintf(stderr, "Suggerimento: Chiudi file non più usati\n");
                break;
                
            case ENFILE:
                fprintf(stderr, "Troppi file aperti nel sistema\n");
                fprintf(stderr, "Suggerimento: Problema di sistema, contatta admin\n");
                break;
                
            case EISDIR:
                fprintf(stderr, "'%s' è una directory, non un file\n", path);
                break;
                
            case ENAMETOOLONG:
                fprintf(stderr, "Path troppo lungo: %s\n", path);
                break;
                
            default:
                // Per errori non gestiti, usa strerror
                fprintf(stderr, "Errore sconosciuto: %s\n", strerror(errno));
                break;
        }
    } else {
        printf("File aperto con successo: fd=%d\n", fd);
        close(fd);
    }
}

Pattern di Gestione Errori

Pattern 1: Controllo Semplice - Exit on Error

Adatto per programmi semplici dove qualsiasi errore è fatale:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    // Apre file - se fallisce, termina immediatamente
    int fd = open("data.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);  // EXIT_FAILURE = 1
    }
    
    // Legge dati - se fallisce, termina
    char buffer[100];
    ssize_t n = read(fd, buffer, sizeof(buffer));
    if (n == -1) {
        perror("read");
        close(fd);  // Cleanup prima di uscire
        exit(EXIT_FAILURE);
    }
    
    close(fd);
    return 0;
}

Pattern 2: Gestione Specifica per Errore

Gestione differenziata basata sul tipo di errore:

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>

int main() {
    const char *filename = "important.txt";
    int fd = open(filename, O_RDONLY);
    
    if (fd == -1) {
        // Gestiamo errori specifici in modo diverso
        if (errno == ENOENT) {
            // File non esiste - potremmo crearlo
            fprintf(stderr, "File '%s' non trovato\n", filename);
            fprintf(stderr, "Vuoi crearlo? ...\n");
            // ...logica per creare file...
            
        } else if (errno == EACCES) {
            // Permessi insufficienti - problema più serio
            fprintf(stderr, "Permesso negato per '%s'\n", filename);
            fprintf(stderr, "Esegui con privilegi maggiori o cambia permessi\n");
            exit(EXIT_FAILURE);
            
        } else if (errno == EMFILE) {
            // Troppi file aperti - problema di risorse
            fprintf(stderr, "Troppi file aperti\n");
            // Potremmo cercare di chiudere file non necessari
            // ...cleanup...
            // ...riprova...
            
        } else {
            // Errore inaspettato
            perror("open");
            exit(EXIT_FAILURE);
        }
    }
    
    // Continua se fd è valido...
    if (fd != -1) {
        // ... usa fd ...
        close(fd);
    }
    
    return 0;
}

Pattern 3: Retry su EINTR (Fondamentale!)

Molte system call possono essere interrotte da segnali. Dobbiamo gestire EINTR:

#include <errno.h>
#include <unistd.h>

// Wrapper per read che gestisce EINTR automaticamente
ssize_t safe_read(int fd, void *buf, size_t count) {
    ssize_t n;
    
    // Loop fino a successo o errore diverso da EINTR
    do {
        n = read(fd, buf, count);
        // Se n == -1 E errno == EINTR, riprova
        // Altrimenti (successo o altro errore), ritorna
    } while (n == -1 && errno == EINTR);
    
    return n;
}

// Stesso pattern per write
ssize_t safe_write(int fd, const void *buf, size_t count) {
    ssize_t n;
    
    do {
        n = write(fd, buf, count);
    } while (n == -1 && errno == EINTR);
    
    return n;
}

// E per altre syscall bloccanti...
pid_t safe_wait(int *status) {
    pid_t pid;
    
    do {
        pid = wait(status);
    } while (pid == -1 && errno == EINTR);
    
    return pid;
}

Perché EINTR è speciale?

// Scenario senza gestione EINTR:
signal(SIGALRM, handler);
alarm(5);  // Alarm tra 5 secondi

char buf[1000];
ssize_t n = read(fd, buf, sizeof(buf));  // Potrebbe bloccare per minuti

if (n == -1) {
    perror("read");  // Stampa "read: Interrupted system call"
    // Ma non è un errore reale! Dovremmo solo riprovare
}

Pattern 4: Cleanup su Errore (goto cleanup)

Pattern molto comune per gestire multiple risorse:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

int process_file(const char *filename) {
    int fd = -1;
    void *buffer = NULL;
    int result = -1;  // Assume fallimento
    
    // ===========================================
    // Tentativo apertura file
    // ===========================================
    fd = open(filename, O_RDONLY);
    if (fd == -1) {
        perror("open");
        goto cleanup;  // Salta al cleanup
    }
    
    // ===========================================
    // Tentativo allocazione buffer
    // ===========================================
    buffer = malloc(4096);
    if (buffer == NULL) {
        fprintf(stderr, "malloc fallita\n");
        goto cleanup;  // fd deve essere chiuso!
    }
    
    // ===========================================
    // Tentativo lettura
    // ===========================================
    ssize_t n = read(fd, buffer, 4096);
    if (n == -1) {
        perror("read");
        goto cleanup;  // fd E buffer devono essere rilasciati!
    }
    
    // ===========================================
    // Successo! Elabora dati
    // ===========================================
    printf("Letti %zd bytes\n", n);
    // ... elaborazione ...
    
    result = 0;  // Successo
    
    // ===========================================
    // Cleanup: Eseguito sempre, successo o errore
    // ===========================================
cleanup:
    // Rilascia risorse in ordine inverso di acquisizione
    
    if (buffer != NULL) {
        free(buffer);
        buffer = NULL;  // Buona pratica
    }
    
    if (fd != -1) {
        close(fd);
        fd = -1;  // Buona pratica
    }
    
    return result;
}

int main() {
    if (process_file("data.txt") == 0) {
        printf("File processato con successo\n");
    } else {
        printf("Errore nel processamento\n");
    }
    return 0;
}

Vantaggi del pattern goto cleanup:

  1. Single point of exit: Un solo punto di uscita dalla funzione
  2. Garantisce cleanup: Le risorse vengono sempre rilasciate
  3. Ordine corretto: Cleanup in ordine inverso di acquisizione
  4. Manutenibilità: Facile aggiungere nuove risorse

Pattern 5: Retry con Exponential Backoff

Per operazioni che possono temporaneamente fallire (es. network):

#include <unistd.h>
#include <errno.h>

int retry_operation(int (*operation)(void), int max_retries) {
    int retry_delay = 1;  // Inizia con 1 secondo
    
    for (int i = 0; i < max_retries; i++) {
        int result = operation();
        
        if (result == 0) {
            // Successo!
            return 0;
        }
        
        // Errore - decidiamo se ritentare
        if (errno == EAGAIN || errno == EBUSY) {
            // Errore temporaneo - riprova dopo delay
            printf("Tentativo %d fallito, riprovo tra %d secondi...\n",
                   i + 1, retry_delay);
            
            sleep(retry_delay);
            
            // Exponential backoff: 1s, 2s, 4s, 8s, ...
            retry_delay *= 2;
            if (retry_delay > 60) {
                retry_delay = 60;  // Massimo 60 secondi
            }
        } else {
            // Errore permanente - non ritentare
            return -1;
        }
    }
    
    // Superato max tentativi
    fprintf(stderr, "Operazione fallita dopo %d tentativi\n", max_retries);
    return -1;
}

Questa gestione completa e robusta degli errori è ciò che distingue codice production-quality da codice didattico o prototipale!

Performance e Overhead

Una delle considerazioni più importanti quando si programma a livello di sistema è comprendere il costo delle system call. Molti programmatori, specialmente quelli che sono nuovi alla programmazione di sistema, trattano le system call come qualsiasi altra funzione, senza rendersi conto che ogni chiamata comporta un overhead significativo.

Costo di una System Call

Ogni system call comporta un overhead dovuto a diverse operazioni che devono essere eseguite per passare dal codice dell’applicazione al kernel e ritorno:

  1. Context Switch: Passaggio da user mode a kernel mode e viceversa

    Questo è il costo più significativo di una system call. Vediamo nel dettaglio cosa succede:

    Salvataggio/ripristino registri CPU: Il processore deve salvare lo stato completo del programma prima di passare al kernel. Questo include tutti i registri general-purpose (RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8-R15 su x86-64), i registri di stato (FLAGS), e il program counter (RIP). Sono decine di registri che devono essere scritti in memoria e poi riletti quando si ritorna. Ogni accesso alla memoria richiede tempo, e anche con le cache L1 veloci, questo accumula latenza.

    Cambio page table: Il processore usa page table diverse per il kernel e per ogni processo utente. Quando si passa al kernel, il registro CR3 (su x86-64) deve essere aggiornato per puntare alla page table del kernel. Questo non solo richiede un’operazione privilegiata, ma causa anche effetti collaterali significativi nelle cache del processore.

    Flush cache TLB (Translation Lookaside Buffer): Il TLB è una cache hardware che memorizza le traduzioni da indirizzi virtuali a indirizzi fisici per velocizzare l’accesso alla memoria. Quando cambiate page table (passando da user space a kernel space), gran parte del TLB deve essere invalidato perché contiene traduzioni che non sono più valide. Questo significa che le successive traduzioni di indirizzi saranno più lente fino a che il TLB non viene riempito nuovamente. Questo effetto è chiamato “TLB miss” e può rallentare significativamente il codice che esegue dopo il ritorno dalla system call.

    Per dare un’idea dell’impatto: un accesso alla memoria con un “TLB hit” (traduzione già in cache) richiede pochi nanosecondi, mentre un “TLB miss” può richiedere centinaia di cicli di clock per camminare la page table in memoria.

  2. Validazione: Il kernel deve validare tutti i parametri

    Il kernel non può fidarsi di nulla che arriva dallo user space. Ogni puntatore, ogni dimensione, ogni flag deve essere controllato. Quando chiamate read(fd, buffer, size), il kernel deve:

    Tutti questi controlli richiedono accessi a strutture dati del kernel, lookup in tabelle, e potenzialmente page walk per verificare i permessi della memoria. Ognuna di queste operazioni richiede tempo.

  3. Sincronizzazione: Potenziali lock nel kernel

    Il kernel Linux è progettato per eseguire su sistemi multiprocessore con decine o centinaia di core. Questo significa che più thread potrebbero tentare di modificare le stesse strutture dati del kernel contemporaneamente. Per prevenire race condition e garantire la consistenza dei dati, il kernel usa lock (mutex, spinlock, read-write lock, etc.).

    Quando chiamate una system call, potreste dover attendere che un lock diventi disponibile. Per esempio, se due processi tentano di scrivere nello stesso file contemporaneamente, il kernel usa un lock per serializzare le operazioni. Il primo processo ottiene il lock immediatamente, ma il secondo deve aspettare. Questo waiting time si aggiunge alla latenza della system call.

    In sistemi con alta contesa (molti core che competono per le stesse risorse), il tempo speso ad aspettare lock può superare il tempo effettivamente speso ad eseguire il lavoro richiesto dalla system call.

Ordine di grandezza temporale:

Per mettere questi numeri in prospettiva, vediamo quanto tempo richiedono diverse operazioni:

Implicazioni pratiche:

Questo overhead significa che se il vostro programma fa milioni di system call al secondo, state sprecando una frazione significativa del tempo della CPU solo nell’overhead di entrare e uscire dal kernel, senza fare lavoro utile.

Esempio concreto: immaginate di voler copiare un file di 1 GB:

// APPROCCIO PESSIMO - circa 1 miliardo di system call!
char byte;
while (read(in_fd, &byte, 1) > 0) {  // Una syscall per ogni byte!
    write(out_fd, &byte, 1);          // Un'altra syscall per ogni byte!
}
// Tempo: potenzialmente minuti!

Ogni byte richiede due system call (una read e una write). Con overhead di 200ns per syscall, abbiamo 400ns per byte, che su 1GB significa 400 secondi (quasi 7 minuti) spesi SOLO nell’overhead, senza contare il tempo di I/O effettivo!

// APPROCCIO MIGLIORE - circa 250,000 system call
char buffer[4096];
ssize_t n;
while ((n = read(in_fd, buffer, sizeof(buffer))) > 0) {
    write(out_fd, buffer, n);
}
// Tempo: secondi

Leggendo 4KB alla volta, riduciamo il numero di system call di un fattore 4096. Questo riduce l’overhead da 400 secondi a meno di 0.1 secondi – un miglioramento di 4000 volte!

Questa è l’importanza di comprendere il costo delle system call e di progettare il codice per minimizzarle.

Strategie di Ottimizzazione

Compreso il costo delle system call, vediamo ora le strategie principali per minimizzare il loro impatto sulle performance. Ogni strategia affronta il problema da un angolo diverso, e spesso la soluzione migliore è una combinazione di più approcci.

1. Buffering - Raggruppare Operazioni

Il buffering è probabilmente la tecnica più importante e più semplice per ridurre il numero di system call. L’idea fondamentale è: invece di fare tante piccole operazioni, raccogliete i dati in un buffer e fate una singola operazione grande.

Vediamo un esempio concreto:

// LENTO: Una system call per ogni byte - 1000 system call!
for (int i = 0; i < 1000; i++) {
    write(fd, &data[i], 1);  // Scrive 1 byte alla volta
}

// Tempo approssimativo:
// 1000 syscall × 200 nanosec = 200,000 nanosec = 0.2 millisecondi
// SOLO per l'overhead, senza contare l'I/O effettivo

In questo approccio, ogni iterazione del loop fa una system call per scrivere un singolo byte. Il kernel deve essere invocato 1000 volte, fare 1000 context switch, validare i parametri 1000 volte. La maggior parte del tempo è speso nell’overhead piuttosto che nel lavoro utile.

// VELOCE: Una sola system call!
write(fd, data, 1000);  // Scrive 1000 byte in una volta

// Tempo approssimativo:
// 1 syscall × 200 nanosec = 200 nanosec
// Miglioramento: 1000× più veloce!

Con una singola chiamata, il kernel viene invocato una sola volta. Il kernel poi scrive tutti i 1000 byte internamente, che è molto più efficiente. Non solo risparmiamo 999 context switch, ma il kernel può anche ottimizzare l’I/O internamente (per esempio, usando DMA - Direct Memory Access - per trasferire grandi blocchi di dati senza coinvolgere la CPU).

Questo principio è alla base del buffering nelle librerie standard come stdio. Quando usate fprintf() o printf(), i dati non vengono scritti immediatamente sul file (o stdout). Vengono invece accumulati in un buffer interno. Solo quando:

viene effettivamente chiamata la system call write(). Questo può ridurre il numero di system call di migliaia di volte in programmi che stampano molto output.

2. Memory Mapping per File Grandi

Memory mapping (mmap) è una tecnica sofisticata che elimina completamente certe system call sostituendole con accessi diretti alla memoria.

// Approccio tradizionale con read/write
char buffer[4096];
while (read(fd, buffer, sizeof(buffer)) > 0) {
    // Processa buffer
    // Per ogni iterazione: 1 system call read()
}
// Se il file è 1GB, abbiamo ~260,000 system call

In questo approccio tradizionale, per leggere un file dovete ripetutamente chiamare read(). Ogni chiamata è una system call con tutto l’overhead che comporta. Inoltre, i dati vengono copiati dal kernel space allo user space (nel vostro buffer), che è un’altra operazione costosa.

// Approccio con memory mapping
char *mapped = mmap(NULL, file_size, PROT_READ, 
                    MAP_PRIVATE, fd, 0);
// UNA SOLA system call per mappare il file!

// Ora possiamo accedere al file come se fosse un array in memoria
for (size_t i = 0; i < file_size; i++) {
    char byte = mapped[i];  // Nessuna system call!
    // Processa byte
}
// Totale system call: 1 (mmap) + 1 (munmap) = 2

Con mmap, mappate l’intero file (o una porzione) nello spazio di indirizzi del vostro processo. Da quel momento in poi, leggere dal file è semplice come leggere da un array – nessuna system call necessaria!

Come funziona sotto il cofano? Quando accedete a mapped[i] per la prima volta, il processore genera un “page fault” perché quella pagina di memoria non è ancora stata caricata in RAM. Il kernel gestisce questo page fault, legge la pagina corrispondente dal disco, la mappa nella memoria del processo, e riprende l’esecuzione. Ma questo succede automaticamente, senza che il vostro codice debba fare nulla, e soprattutto senza system call esplicite per ogni operazione di lettura.

Inoltre, il kernel può fare “prefetching” – se vede che state leggendo sequenzialmente, può precaricare le pagine successive in background, migliorando ulteriormente le performance.

Quando usare mmap:

Quando NON usare mmap:

3. Vectored I/O - Operazioni Scatter/Gather

Vectored I/O (o scatter/gather I/O) permette di leggere o scrivere da/verso multipli buffer in una singola system call.

// Situazione: avete dati in più buffer separati
char header[100];
char payload[1000];
char footer[50];

// Approccio tradizionale: 3 system call
write(fd, header, 100);
write(fd, payload, 1000);
write(fd, footer, 50);
// 3 system call, 3 context switch

Questo è un pattern comune: avete dati che logicamente formate parti di un messaggio ma sono memorizzati in buffer separati in memoria. Forse header e footer sono strutture fisse, mentre payload è dinamicamente allocato. Scriverli separatamente richiede tre system call.

// Approccio con writev: 1 sola system call!
struct iovec iov[3];

iov[0].iov_base = header;
iov[0].iov_len = 100;

iov[1].iov_base = payload;
iov[1].iov_len = 1000;

iov[2].iov_base = footer;
iov[2].iov_len = 50;

writev(fd, iov, 3);
// 1 system call, 1 context switch
// 3× più veloce per quanto riguarda l'overhead

writev() (e la corrispondente readv() per lettura) accetta un array di strutture iovec, ognuna che descrive un buffer. Il kernel legge tutti questi buffer in una singola operazione atomica, scrivendo i dati nell’ordine specificato.

Vantaggi:

Casi d’uso reali:

4. I/O Asincrono - Non Bloccare in Attesa

I/O asincrono (AIO) permette di iniziare operazioni di I/O senza bloccare il processo in attesa del completamento.

// I/O sincrono tradizionale:
ssize_t n = read(fd, buffer, size);  
// Il processo si BLOCCA qui fino al completamento
// La CPU non può fare altro lavoro nel frattempo

Con I/O sincrono, quando chiamate read(), il vostro processo viene messo nello stato “blocked” dallo scheduler. Non può eseguire nulla fino a che i dati non sono disponibili. Se state leggendo da un disco lento o da una rete, questo potrebbe richiedere millisecondi (o anche secondi).

// I/O asincrono:
struct aiocb cb;
memset(&cb, 0, sizeof(cb));
cb.aio_fildes = fd;
cb.aio_buf = buffer;
cb.aio_nbytes = size;

aio_read(&cb);  // Inizia la lettura, NON si blocca!

// Ora possiamo fare altro lavoro utile mentre I/O procede
do_other_work();
process_more_requests();
compute_something();

// Quando servono i dati, controlliamo se sono pronti
while (aio_error(&cb) == EINPROGRESS) {
    // Ancora in corso, facciamo altro...
    do_more_work();
}

// I/O completato, prendiamo il risultato
ssize_t n = aio_return(&cb);

Con AIO, aio_read() ritorna immediatamente dopo aver iniziato l’operazione. Il kernel continua a leggere i dati in background, mentre il vostro processo può continuare ad eseguire. Questo è particolarmente potente in applicazioni server che devono gestire molte connessioni contemporaneamente.

Benefici:

Svantaggi:

Modern alternative: Su Linux moderno, io_uring è un nuovo framework per I/O asincrono molto più efficiente di AIO POSIX. È usato da database ad alte performance come PostgreSQL e da application server che necessitano di massimo throughput.

In sintesi: la chiave per performance ottimali è minimizzare il numero di system call. Che sia attraverso buffering, memory mapping, vectored I/O, o I/O asincrono, l’obiettivo è sempre lo stesso: fare più lavoro con meno invocazioni del kernel.

Tool per Analizzare System Call

strace: System Call Tracer

strace è uno strumento essenziale per tracciare e analizzare le system call eseguite da un processo. È il tool più usato per:

Sintassi base e opzioni comuni:

# ===========================================
# USO BASILARE
# ===========================================

# Traccia tutte le system call di un comando
strace ls -l

# Output mostra ogni syscall:
# execve("/bin/ls", ["ls", "-l"], 0x7ffe8a... = 0
# brk(NULL)                    = 0x55a1a7...
# openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
# ...

# ===========================================
# FILTRAGGIO PER TIPO DI SYSCALL
# ===========================================

# Traccia solo system call specifiche
strace -e open,read,write ls
# Mostra solo open, read, write - ignora tutte le altre

# Traccia multiple syscall
strace -e open,openat,close,read,write cat file.txt

# Traccia categorie di syscall
strace -e trace=file ls          # Solo syscall legate a file
strace -e trace=process ls       # Solo syscall legate a processi
strace -e trace=network ls       # Solo syscall di rete
strace -e trace=signal ls        # Solo syscall di segnali
strace -e trace=ipc ls           # Solo syscall IPC
strace -e trace=memory ls        # Solo syscall di memoria

# Traccia tutto TRANNE alcune syscall
strace -e \!open,close ls        # Tutto tranne open e close

# ===========================================
# TIMESTAMP E TIMING
# ===========================================

# Mostra timestamp assoluti
strace -t ls
# Output: 14:23:45 open("/etc/ld.so.cache", O_RDONLY) = 3

# Timestamp con microsecondi
strace -tt ls
# Output: 14:23:45.123456 open(...) = 3

# Timestamp relativi (dall'inizio)
strace -r ls
# Output:  0.000123 open(...) = 3
#          0.000045 read(...) = 832

# Mostra durata di ogni syscall
strace -T ls
# Output: open("/etc/ld.so.cache", ...) = 3 <0.000123>
#                                              ^^^^^^^^ tempo in secondi

# ===========================================
# STATISTICHE E PROFILING
# ===========================================

# Mostra statistiche aggregate (molto utile!)
strace -c ls

# Output:
# % time     seconds  usecs/call     calls    errors syscall
# ------ ----------- ----------- --------- --------- ----------------
#  52.34    0.014553           7      2016           getdents
#  19.45    0.005407          13       397           newfstatat
#  10.23    0.002843          23       120           openat
#   8.76    0.002436          20       120           close
#   5.12    0.001423          11       120           fstat
#  ...
# ------ ----------- ----------- --------- --------- ----------------
# 100.00    0.027802                  2893       177 total

# Combinazione: traccia E statistiche
strace -c -T ls

# ===========================================
# TRACCIARE PROCESSI ESISTENTI
# ===========================================

# Attacca a un processo già in esecuzione (richiede permessi)
strace -p <PID>

# Esempio: traccia un server web in esecuzione
strace -p 1234

# Per staccare: Ctrl+C

# Traccia processo e mostra PID di ogni syscall
strace -f -p 1234   # -f segue i fork

# ===========================================
# SEGUIRE FORK E THREAD
# ===========================================

# Segue anche i processi figli (fork)
strace -f ./programma

# Mostra PID prima di ogni syscall
strace -ff ./programma
# Output: [pid 12345] open(...) = 3
#         [pid 12346] read(...) = 100

# Crea file separati per ogni processo
strace -ff -o output ./programma
# Crea: output.12345, output.12346, etc.

# ===========================================
# OUTPUT E LOGGING
# ===========================================

# Scrivi output su file invece di stderr
strace -o trace.log ls

# Append invece di sovrascrivere
strace -o trace.log -A ls

# Output più leggibile con indentazione
strace -i ls  # Mostra instruction pointer

# Stringhe più lunghe (default 32 char)
strace -s 128 ls  # Mostra 128 caratteri per stringa

# Mostra contenuto buffer in formato esadecimale
strace -x ls

# ===========================================
# DEBUG AVANZATO
# ===========================================

# Mostra valori degli argomenti
strace -v ls  # Verbose - mostra tutti i campi delle struct

# Decodifica socket e network
strace -e trace=network curl https://example.com

# Mostra numeri syscall grezzi
strace -n ls

# ===========================================
# TROUBLESHOOTING SPECIFICO
# ===========================================

# Perché il programma è lento?
strace -c -T ./programma_lento
# Guarda la colonna "time" per trovare syscall costose

# Dove cerca file la mia app?
strace -e openat ./myapp
# Mostra tutti i tentativi di apertura file

# Perché la connessione fallisce?
strace -e trace=network ./client
# Mostra connect, socket, send, recv

# Il programma fa troppe syscall?
strace -c ./programma
# Se vedi migliaia di chiamate piccole, c'è problema di buffering

Esempio pratico completo di debugging con strace:

Problema: Un programma è lento all’avvio. Usiamo strace per investigare.

# Passo 1: Traccia con timestamp e durata
$ strace -tt -T ./slow_program 2>&1 | head -50

14:23:45.123456 execve("./slow_program", ...) = 0 <0.000234>
14:23:45.124001 brk(NULL) = 0x55a1a7000000 <0.000011>
14:23:45.124089 openat(AT_FDCWD, "/etc/ld.so.cache", ...) = 3 <0.000234>
14:23:45.124456 openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", ...) = 3 <0.000123>
...
14:23:45.567234 openat(AT_FDCWD, "/home/user/.config/app/config.txt", ...) = -1 ENOENT <0.000045>
14:23:45.567345 openat(AT_FDCWD, "/home/user/.config/app/default.txt", ...) = -1 ENOENT <0.000043>
14:23:45.567421 openat(AT_FDCWD, "/etc/app/config.txt", ...) = -1 ENOENT <0.000041>
14:23:45.567498 openat(AT_FDCWD, "/etc/app/default.txt", ...) = 3 <0.000123>
14:23:46.789123 read(3, "config data...", 4096) = 234 <1.221234>
                                                           ^^^^^^^^ PROBLEMA QUI!
...

# Passo 2: Statistiche per confermare
$ strace -c ./slow_program

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 95.23    1.234567     1234567         1           read
  2.34    0.030123          45       670           openat
  ...

# DIAGNOSI: 
# - Molti tentativi di apertura file falliti (overhead)
# - Una read() impiega 1.22 secondi (file su network mount lento?)
# - SOLUZIONE: Cache la config, evita network mount, o ottimizza I/O

Interpretare l’output di strace:

# Anatomia di una riga di strace:
openat(AT_FDCWD, "/etc/passwd", O_RDONLY) = 3 <0.000234>
│      │         │               │          │  └─ Tempo impiegato
│      │         │               │          └─ Valore di ritorno
│      │         │               └─ Terzo parametro (flags)
│      │         └─ Secondo parametro (pathname)
│      └─ Primo parametro (dirfd)
└─ Nome system call

# Chiamate fallite:
open("/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)
                                  │   └─ Codice errno e descrizione
                                  └─ Ritorno -1 indica errore

# Parametri complessi (struct):
stat("/etc/passwd", {st_mode=S_IFREG|0644, st_size=1234, ...}) = 0
                    └─ Contenuto della struct stat

# Segnali:
--- SIGINT {si_signo=SIGINT, si_code=SI_KERNEL} ---
└─ Indica che il processo ha ricevuto SIGINT

# Interruzioni:
read(3, <unfinished ...>
<... read resumed>"data", 100) = 100
└─ System call interrotta (es. da altro thread), poi ripresa

Casi d’uso reali di strace:

1. Programma che non trova file:

$ strace -e openat ./myapp 2>&1 | grep -i config
openat(AT_FDCWD, "/home/user/.config/myapp.conf", ...) = -1 ENOENT
openat(AT_FDCWD, "/etc/myapp.conf", ...) = 3

# SOLUZIONE: File deve essere in /etc/myapp.conf

2. Programma che non si connette:

$ strace -e trace=network ./client 2>&1 | grep connect
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(8080), 
            sin_addr=inet_addr("127.0.0.1")}, 16) = -1 ECONNREFUSED

# SOLUZIONE: Nessun server in ascolto su localhost:8080

3. Programma che impazzisce in loop:

$ strace -c ./looping_program
# Ctrl+C dopo qualche secondo

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 99.99    5.234567           0  10234567           write

# SOLUZIONE: 10 milioni di write! C'è un loop che scrive continuamente

4. Permission denied ma non si capisce dove:

$ strace -e openat,access ./myapp 2>&1 | grep EACCES
access("/secret/file", R_OK) = -1 EACCES (Permission denied)

# SOLUZIONE: Il file /secret/file non è leggibile

5. Performance analysis:

# Conta le syscall per secondo
$ strace -c -f ./web_server &
$ # ... genera traffico ...
$ kill $!

# Se vedi molte piccole read/write, il buffering è insufficiente
# Se vedi molte stat/openat, c'è thrashing su filesystem

Limitazioni di strace:

  1. Overhead significativo: strace può rallentare programmi del 50-100x
  2. Non traccia internals del kernel: vede solo l’interfaccia syscall
  3. Output voluminoso: programmi complessi generano gigabyte di trace
  4. Interrompe debugger: non usabile con gdb contemporaneamente

Alternative e tool complementari:

# ltrace - Traccia chiamate a librerie (non syscall)
ltrace ./program

# perf - Performance profiler più leggero
perf trace ./program
perf stat -e 'syscalls:*' ./program

# ftrace - Tracing a livello kernel (più potente ma più complesso)
# eBPF/BCC - Tracing programmabile e performante

Script utile per analisi strace:

#!/bin/bash
# analyze_strace.sh - Analizza output di strace

echo "=== Top 10 syscall più frequenti ==="
grep -oP '^[^(]+' strace.log | sort | uniq -c | sort -rn | head -10

echo -e "\n=== Errori più frequenti ==="
grep '= -1' strace.log | grep -oP 'E[A-Z]+' | sort | uniq -c | sort -rn

echo -e "\n=== File non trovati ==="
grep 'ENOENT' strace.log | grep -oP '"[^"]+"' | sort -u

echo -e "\n=== Syscall più lente (>0.1s) ==="
grep -P '<0\.[1-9]|<[1-9]' strace.log | head -20

strace è uno strumento indispensabile nella toolbox di ogni programmatore di sistema!

ltrace: Library Call Tracer

Simile a strace ma traccia chiamate a librerie (non system call):

ltrace ls

perf: Performance Profiler

# Conta system call
perf stat -e 'syscalls:sys_enter_*' ls

# Registra e analizza
perf record -e 'syscalls:*' ls
perf report

SystemTap e eBPF

Strumenti avanzati per tracing e profiling a livello kernel.

Esempio SystemTap:

# Script che conta system call per processo
stap -e 'probe syscall.* { 
    counts[execname()] <<< 1 
} 
probe end { 
    foreach (name in counts+) 
        printf("%s: %d\n", name, @count(counts[name]))
}'

Differenze tra Sistemi Operativi

Linux vs Unix (POSIX)

Linux implementa lo standard POSIX ma aggiunge system call specifiche:

System Call Solo Linux:

// Linux-specific
epoll_create()      // I/O multiplexing efficiente
epoll_wait()
inotify_init()      // File system monitoring
inotify_add_watch()
eventfd()           // Event notification
signalfd()          // Signal handling via file descriptor
timerfd_create()    // Timer via file descriptor
splice()            // Zero-copy pipe operations
tee()
sendfile()          // Zero-copy file sending

Linux vs Windows

Windows usa un modello completamente diverso:

Confronto:

Linux/Unix Windows Funzione
fork() CreateProcess() Crea processo
open() CreateFile() Apre file
read(), write() ReadFile(), WriteFile() I/O file
mmap() CreateFileMapping() Memory mapping
socket() WSASocket() Crea socket
fork() + exec() Non esiste equivalente diretto Pattern Unix

Windows non ha fork() - usa direttamente CreateProcess() che combina creazione processo ed esecuzione programma.

macOS (XNU Kernel)

macOS (basato su Darwin/XNU) è POSIX-compliant con alcune estensioni:

// macOS-specific
kqueue()            // Event notification (simile a epoll)
kevent()

Wrapper e Librerie di Alto Livello

La Libreria Standard C (libc)

La libc fornisce wrapper attorno alle system call:

Esempio: printf() vs write()

// Alto livello: printf (libc)
printf("Hello, world!\n");

// Basso livello: write (system call diretta)
write(STDOUT_FILENO, "Hello, world!\n", 14);

printf() internamente:

  1. Formatta la stringa in un buffer
  2. Gestisce buffering
  3. Eventualmente chiama write() quando il buffer è pieno

Vantaggi wrapper libc:

Quando usare system call dirette:

Buffer nell’I/O Standard

#include <stdio.h>

FILE *fp = fopen("file.txt", "w");

// Buffered: non chiama subito write()
fprintf(fp, "Line 1\n");  // Dati nel buffer
fprintf(fp, "Line 2\n");  // Dati nel buffer
fprintf(fp, "Line 3\n");  // Dati nel buffer

// Forza flush (system call write)
fflush(fp);

// La chiusura fa automaticamente flush
fclose(fp);

Tipi di buffering:

// No buffering: ogni operazione -> system call immediata
setbuf(fp, NULL);

// Line buffering: flush su newline
setvbuf(fp, NULL, _IOLBF, 0);

// Full buffering: flush quando buffer pieno
setvbuf(fp, NULL, _IOFBF, BUFSIZ);

System Call vs Function Call

Confronto

Aspetto System Call Function Call
Esecuzione Kernel mode User mode
Overhead Alto (~100-300ns) Basso (~1-5ns)
Context switch No
Accesso hardware No
Sicurezza Validazione kernel Nessuna validazione
Portabilità Standard POSIX Dipende
Esempi read(), write(), fork() strlen(), strcpy(), malloc()

Quando Usare System Call Dirette

Usa system call dirette quando:

  1. Necessità di controllo fine

    // Controllo esatto su buffering
    write(fd, data, size);  // Scrive immediatamente
    
  2. Performance critiche

    // Zero-copy con sendfile
    sendfile(out_fd, in_fd, NULL, size);
    
  3. Funzionalità non wrapped

    // epoll non ha wrapper standard
    epoll_create1(EPOLL_CLOEXEC);
    
  4. Programming di sistema

    // Creazione demoni, gestione processi, ecc.
    if (fork() == 0) {
        setsid();  // Crea nuova sessione
        // ...
    }
    

Usa funzioni di libreria quando:

  1. Portabilità importante
  2. Buffering desiderato
  3. API di alto livello sufficiente
  4. Applicazioni generiche

Best Practices

Scrivere codice che usa system call in modo corretto ed efficiente richiede attenzione a molti dettagli che spesso vengono trascurati da programmatori inesperti. Qui esaminiamo le pratiche fondamentali che ogni programmatore di sistema dovrebbe seguire.

1. Controllo Errori Sempre - Nessuna Eccezione

Questa è forse la regola più importante e più violata. Ogni system call può fallire, e ignorare gli errori porta a bug subdoli e difficili da tracciare.

// ❌ SBAGLIATO - Codice pericoloso e inaffidabile
int fd = open(filename, O_RDONLY);
read(fd, buffer, size);  // Se open è fallito, fd è -1!

Cosa c’è di sbagliato in questo codice? Se open() fallisce (il file non esiste, permessi insufficienti, troppi file aperti, etc.), ritorna -1. Il codice poi passa -1 a read(), che è un file descriptor completamente invalido. La read() fallirà certamente, ma il programma continua ignaro. I dati in buffer resteranno non inizializzati (spazzatura casuale), e il programma potrebbe usare questi dati corrotti per prendere decisioni o fare calcoli, portando a risultati completamente errati.

Peggio ancora, il programma potrebbe sembrare funzionare “nella maggior parte dei casi” (quando open ha successo), fallendo solo occasionalmente in modi difficili da riprodurre. Questo tipo di bug è un incubo da debuggare in produzione.

// ✅ CORRETTO - Controllo errori appropriato
int fd = open(filename, O_RDONLY);
if (fd == -1) {
    // open() ha fallito - gestiamo l'errore appropriatamente
    perror("open");  // Stampa messaggio di errore descrittivo
    exit(EXIT_FAILURE);  // Termina con codice di errore
}

// Ora sappiamo con certezza che fd è valido
ssize_t n = read(fd, buffer, size);
if (n == -1) {
    // read() ha fallito - anche questo va gestito
    perror("read");
    close(fd);  // Cleanup: chiudi il file prima di uscire
    exit(EXIT_FAILURE);
}

// Ora sappiamo che abbiamo letto n byte validi

In questa versione corretta:

Perché questo è così critico:

  1. Affidabilità: I programmi production devono gestire errori gracefully
  2. Debug: Messaggi di errore chiari accelerano enormemente il debugging
  3. Sicurezza: Errori non gestiti possono essere vettori di attacco (es. buffer overflow se assumete che read abbia successo)
  4. Manutenibilità: Codice con error handling è più facile da manutenere e modificare

2. Gestione EINTR - Interruzioni da Segnali

EINTR (Interrupted system call) è un errore speciale che merita attenzione particolare. Molte system call “bloccanti” possono essere interrotte da segnali.

Cosa significa “bloccante”? Una system call bloccante è una che può mettere il processo in stato di sleep, aspettando qualche evento. Per esempio:

Ora, cosa succede se mentre il vostro processo è bloccato in una di queste system call, arriva un segnale (per esempio SIGINT da Ctrl+C, o SIGALRM da un timer, o SIGCHLD quando un figlio termina)?

Il kernel deve svegliare il vostro processo per consegnare il segnale. L’handler del segnale viene eseguito, e quando ritorna… cosa dovrebbe fare la system call originale? Potrebbe continuare ad aspettare, ma questo comportamento può essere problematico. Per default, molte system call vengono invece interrotte e ritornano l’errore EINTR.

// ⚠️ Versione fragile - non gestisce EINTR
ssize_t n = read(fd, buffer, size);
if (n == -1) {
    perror("read");  // Potrebbe stampare "read: Interrupted system call"
    return -1;  // Trattiamo come errore reale, ma è solo un'interruzione!
}

In questo codice, se read() viene interrotta da un segnale, trattiamo l’interruzione come un errore fatale. Ma EINTR non è realmente un errore – significa solo “riprova”. I dati non sono stati letti perché siete stati interrotti, ma potreste provare di nuovo.

// ✅ Versione robusta - gestisce EINTR correttamente
ssize_t safe_read(int fd, void *buf, size_t count) {
    ssize_t n;
    
    // Loop: continua a provare finché non hai successo o un errore reale
    do {
        n = read(fd, buf, count);
        // Se n == -1 E errno == EINTR:
        //   La read è stata interrotta da un segnale
        //   Riprova immediatamente (il loop continua)
        // Se n == -1 E errno != EINTR:
        //   Errore reale, esci dal loop e ritorna -1
        // Se n >= 0:
        //   Successo, esci dal loop e ritorna n
    } while (n == -1 && errno == EINTR);
    
    return n;  // Ritorna numero byte letti o -1 per errore reale
}

Questo wrapper “safe_read” gestisce EINTR automaticamente. Il loop do-while continua a chiamare read() finché non ha successo o non riceve un errore diverso da EINTR.

Nota importante: Non tutte le system call ritornano EINTR. Alcune sono “automatically restarted” dal kernel se avete impostato il flag SA_RESTART quando avete installato l’handler del segnale. Ma non potete fare affidamento su questo in modo portabile, quindi è meglio gestire EINTR esplicitamente.

Dovreste creare wrapper simili per altre system call bloccanti:

ssize_t safe_write(int fd, const void *buf, size_t count) {
    ssize_t n;
    do {
        n = write(fd, buf, count);
    } while (n == -1 && errno == EINTR);
    return n;
}

pid_t safe_wait(int *status) {
    pid_t pid;
    do {
        pid = wait(status);
    } while (pid == -1 && errno == EINTR);
    return pid;
}

pid_t safe_waitpid(pid_t pid, int *status, int options) {
    pid_t result;
    do {
        result = waitpid(pid, status, options);
    } while (result == -1 && errno == EINTR);
    return result;
}

3. Cleanup Risorse - Evitare Resource Leak

I file descriptor, la memoria allocata, i lock – tutte queste sono risorse limitate che devono essere rilasciate quando non servono più. I “resource leak” (perdite di risorse) sono bug comuni che causano problemi seri in programmi long-running.

Cosa succede se non chiudete i file descriptor? Ogni processo ha un limite sul numero di file che può avere aperti contemporaneamente (tipicamente 1024 per default, controllabile con ulimit -n). Se continuate ad aprire file senza chiuderli, eventualmente raggiungerete questo limite e le successive chiamate a open() falliranno con EMFILE (Too many open files).

// ❌ Versione con potenziale resource leak
int process_file(const char *filename) {
    int fd = -1;
    void *buffer = NULL;
    int result = -1;  
    
    // Tentativo apertura file
    fd = open(filename, O_RDONLY);
    if (fd == -1) {
        perror("open");
        return -1;  // Ok, nessuna risorsa allocata
    }
    
    // Tentativo allocazione buffer
    buffer = malloc(BUFFER_SIZE);
    if (buffer == NULL) {
        fprintf(stderr, "malloc fallita\n");
        // ⚠️ BUG: fd è aperto ma non lo chiudiamo!
        return -1;  // Resource leak!
    }
    
    // Tentativo lettura
    ssize_t n = read(fd, buffer, BUFFER_SIZE);
    if (n == -1) {
        perror("read");
        // ⚠️ BUG: non liberiamo buffer né chiudiamo fd!
        return -1;  // Resource leak doppio!
    }
    
    // Elaborazione...
    
    // Cleanup solo se arriviamo qui
    free(buffer);
    close(fd);
    return 0;
}

In questo codice, se malloc fallisce, usciamo senza chiudere fd. Se read() fallisce, perdiamo sia buffer che fd. In un programma che chiama questa funzione migliaia di volte, questi leak si accumulano fino a esaurire le risorse del sistema.

// ✅ Versione corretta con pattern goto cleanup
int process_file(const char *filename) {
    int fd = -1;
    void *buffer = NULL;
    int result = -1;  // Assume fallimento per default
    
    // === Acquisizione risorse ===
    
    fd = open(filename, O_RDONLY);
    if (fd == -1) {
        perror("open");
        goto cleanup;  // Salta al cleanup (niente da liberare)
    }
    
    buffer = malloc(BUFFER_SIZE);
    if (buffer == NULL) {
        fprintf(stderr, "malloc fallita\n");
        goto cleanup;  // Cleanup chiuderà fd
    }
    
    // === Operazioni ===
    
    ssize_t n = read(fd, buffer, BUFFER_SIZE);
    if (n == -1) {
        perror("read");
        goto cleanup;  // Cleanup libererà buffer e chiuderà fd
    }
    
    // Elaborazione dati...
    process_data(buffer, n);
    
    result = 0;  // Successo!
    
    // === Cleanup: eseguito SEMPRE ===
cleanup:
    // Libera risorse in ordine inverso di acquisizione
    
    if (buffer != NULL) {
        free(buffer);
        buffer = NULL;  // Buona pratica: azzera dopo free
    }
    
    if (fd != -1) {
        if (close(fd) == -1) {
            perror("close");  // Anche close può fallire!
        }
        fd = -1;  // Buona pratica: azzera dopo close
    }
    
    return result;
}

Il pattern goto cleanup garantisce che le risorse vengano sempre liberate, indipendentemente da quale punto del codice si esce. Questo approccio:

  1. È robusto: Impossibile dimenticare di liberare una risorsa
  2. È manutenibile: Se aggiungi una nuova risorsa, aggiungi solo una linea nella sezione cleanup
  3. È chiaro: C’è un solo punto di uscita dalla funzione
  4. È efficiente: Nessun overhead runtime rispetto a cleanup inline

Dettagli importanti:

Questo pattern può sembrare verboso, ma è lo standard in codice C production-quality. Progetti come il kernel Linux lo usano estensivamente.

4. Atomicità Operazioni

// NON atomico: race condition
if (access(filename, F_OK) == -1) {
    // File non esiste
    int fd = open(filename, O_CREAT | O_WRONLY, 0644);
    // Ma potrebbe essere stato creato nel frattempo!
}

// Atomico: usa O_EXCL
int fd = open(filename, O_CREAT | O_EXCL | O_WRONLY, 0644);
if (fd == -1) {
    if (errno == EEXIST) {
        // File già esistente
    }
}

5. Scegliere System Call Giuste

// Inefficiente per file grandi
char buf[4096];
while ((n = read(fd, buf, sizeof(buf))) > 0) {
    write(out_fd, buf, n);
}

// Più efficiente: sendfile (zero-copy)
sendfile(out_fd, fd, NULL, file_size);

// Oppure: mmap per accesso casuale
char *data = mmap(NULL, file_size, PROT_READ, 
                  MAP_PRIVATE, fd, 0);

6. Non Assumere Successo Parziale

// SBAGLIATO
write(fd, buffer, size);  // Potrebbe scrivere meno di size!

// CORRETTO
size_t total_written = 0;
while (total_written < size) {
    ssize_t n = write(fd, buffer + total_written, 
                     size - total_written);
    if (n == -1) {
        if (errno == EINTR) continue;
        perror("write");
        return -1;
    }
    total_written += n;
}

7. Usa Flags Moderni

// Vecchio stile
int fd = open(filename, O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);

// Moderno: atomico
int fd = open(filename, O_RDONLY | O_CLOEXEC);

Sicurezza e System Call

1. Validazione Parametri

Il kernel valida tutti i parametri, ma alcune validazioni devono essere fatte dall’applicazione:

// Controlla path relativi
const char *safe_open(const char *base_dir, const char *filename) {
    // Previeni path traversal
    if (strstr(filename, "..") != NULL) {
        errno = EINVAL;
        return NULL;
    }
    
    char fullpath[PATH_MAX];
    snprintf(fullpath, sizeof(fullpath), "%s/%s", base_dir, filename);
    
    return realpath(fullpath, NULL);
}

2. Race Condition (TOCTOU)

Time-of-Check to Time-of-Use bugs:

// VULNERABILE: TOCTOU race condition
if (access(filename, W_OK) == 0) {
    // File scrivibile al momento del check
    int fd = open(filename, O_WRONLY);
    // Ma potrebbe essere cambiato nel frattempo!
    write(fd, data, size);
}

// SICURO: tenta direttamente
int fd = open(filename, O_WRONLY);
if (fd == -1) {
    if (errno == EACCES) {
        // Gestisci errore permessi
    }
} else {
    write(fd, data, size);
}

3. Setuid Programs

Programmi con bit setuid richiedono attenzione speciale:

// Controlla UID reale vs effettivo
uid_t real_uid = getuid();
uid_t effective_uid = geteuid();

if (real_uid != effective_uid) {
    // Running setuid - extra cautela
    
    // Valida environment
    clearenv();
    
    // Imposta PATH sicuro
    setenv("PATH", "/bin:/usr/bin", 1);
    
    // Valida rigorosamente tutti input
}

4. System Call Filtering (seccomp)

Linux permette di limitare le system call disponibili:

#include <sys/prctl.h>
#include <linux/seccomp.h>

// Abilita strict seccomp: solo read, write, exit, sigreturn
prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);

// Ora qualsiasi altra system call terminerà il processo

Debugging e Troubleshooting

Pattern di Debug

1. Logging delle System Call

#define SYSCALL_LOG(call, ...) do { \
    fprintf(stderr, "[SYSCALL] %s:%d: " #call "\n", \
            __FILE__, __LINE__); \
    call(__VA_ARGS__); \
} while(0)

SYSCALL_LOG(open, "/etc/passwd", O_RDONLY);

2. Verifica Errori Sistematicamente

#define CHECK_SYSCALL(call) do { \
    if ((call) == -1) { \
        fprintf(stderr, "[ERROR] %s:%d: %s failed: %s\n", \
                __FILE__, __LINE__, #call, strerror(errno)); \
        exit(EXIT_FAILURE); \
    } \
} while(0)

int fd;
CHECK_SYSCALL(fd = open("/etc/passwd", O_RDONLY));

3. Wrapper con Timeout

// Esempio: read con timeout
ssize_t read_with_timeout(int fd, void *buf, size_t count, 
                         int timeout_sec) {
    fd_set readfds;
    struct timeval tv;
    
    FD_ZERO(&readfds);
    FD_SET(fd, &readfds);
    
    tv.tv_sec = timeout_sec;
    tv.tv_usec = 0;
    
    int ready = select(fd + 1, &readfds, NULL, NULL, &tv);
    if (ready == -1) {
        return -1;  // Errore
    } else if (ready == 0) {
        errno = ETIMEDOUT;
        return -1;  // Timeout
    }
    
    return read(fd, buf, count);
}

Tool di Analisi

1. strace - già discusso sopra

2. ltrace - Library calls

ltrace -c ./programma  # Statistiche
ltrace -f ./programma  # Segui fork

3. gdb - Debugging

gdb ./programma
(gdb) catch syscall open
(gdb) run
# Si ferma ad ogni open()

4. valgrind - Memory checking

valgrind --leak-check=full ./programma

5. perf - Performance analysis

perf stat ./programma
perf record -e syscalls:* ./programma
perf report

Esercizi Pratici

Esercizio 1: Copia File con System Call

Implementare un programma che copi un file usando solo system call (open, read, write, close).

Requisiti:

Esercizio 2: Process Monitor

Creare un programma che monitorizzi un processo usando system call.

Funzionalità:

Esercizio 3: Simple Shell

Implementare una shell minimale che supporti:

Esercizio 4: File Monitoring

Usare inotify (Linux) per monitorare modifiche a file/directory.

Eventi da tracciare:

Esercizio 5: Network Server

Implementare un server TCP echo usando:

Esercizio 6: Memory Mapper

Programma che usa mmap per:

Esercizio 7: Timer e Allarmi

Implementare un sistema di timer usando:

Esercizio 8: System Call Tracer

Creare un mini-tracer che usa ptrace per:

Esercizio 9: Resource Limits

Programma che esplora e modifica limiti di risorse:

Esercizio 10: Advanced IPC

Sistema di comunicazione complesso usando:

Riferimenti e Approfondimenti

Manuali Essenziali

# System call specifiche
man 2 read
man 2 write
man 2 open
man 2 fork

# Concetti generali
man 7 signal
man 7 socket
man 7 pipe

# Funzioni libreria C
man 3 printf
man 3 malloc

Standard

Libri Consigliati

  1. “Advanced Programming in the UNIX Environment” - Stevens & Rago

  2. “The Linux Programming Interface” - Michael Kerrisk

  3. “Linux System Programming” - Robert Love

  4. “Understanding the Linux Kernel” - Bovet & Cesati

Risorse Online

Tool e Utilità

Conclusione

Le system call rappresentano l’interfaccia fondamentale tra applicazioni e sistema operativo. La loro comprensione è essenziale per:

  1. Sviluppo efficace: Scrivere codice che sfrutta al meglio le risorse del sistema
  2. Performance: Ottimizzare applicazioni riducendo overhead
  3. Debugging: Analizzare e risolvere problemi a basso livello
  4. Sicurezza: Comprendere i confini tra user space e kernel space
  5. Portabilità: Scrivere codice che funziona su sistemi diversi

Concetti chiave da ricordare:

Prospettive future:

La padronanza delle system call vi permetterà di sviluppare software di sistema robusto, efficiente e sicuro, comprendendo a fondo come le applicazioni interagiscono con il sistema operativo sottostante.


Questa lezione è stata preparata per il corso di Sistemi e Reti dell’Università Marconi Verona.